config_preview_deploy-1.0.0-alpha3/src/ConfigVerifier.php
src/ConfigVerifier.php
<?php
declare(strict_types=1);
namespace Drupal\config_preview_deploy;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\State\StateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use GuzzleHttp\ClientInterface;
/**
* Verifies configuration state between environments.
*/
class ConfigVerifier {
use StringTranslationTrait;
/**
* The HTTP client.
*/
protected ClientInterface $httpClient;
/**
* The state service.
*/
protected StateInterface $state;
/**
* Logger channel.
*/
protected LoggerChannelInterface $logger;
/**
* The configuration factory.
*/
protected ConfigFactoryInterface $configFactory;
/**
* The date formatter service.
*/
protected DateFormatterInterface $dateFormatter;
/**
* The private tempstore factory.
*/
protected PrivateTempStoreFactory $privateTempStoreFactory;
/**
* The hash verification service.
*
* @var \Drupal\config_preview_deploy\HashVerification
*/
protected HashVerification $hashVerification;
/**
* Constructs a ConfigVerifier object.
*/
public function __construct(
ClientInterface $httpClient,
StateInterface $state,
LoggerChannelFactoryInterface $loggerFactory,
ConfigFactoryInterface $configFactory,
DateFormatterInterface $dateFormatter,
PrivateTempStoreFactory $privateTempStoreFactory,
HashVerification $hashVerification,
) {
$this->httpClient = $httpClient;
$this->state = $state;
$this->configFactory = $configFactory;
$this->logger = $loggerFactory->get('config_preview_deploy');
$this->dateFormatter = $dateFormatter;
$this->privateTempStoreFactory = $privateTempStoreFactory;
$this->hashVerification = $hashVerification;
}
/**
* Validates incoming verification request on production.
*
* This method is called on the production environment to validate
* verification requests from preview environments.
*
* @param array $request
* The verification request data.
*
* @return array
* Validation result with verification data.
*
* @throws \RuntimeException
* When authentication fails.
*/
public function validateVerificationRequest(array $request): array {
// Verify authentication hash including timestamp.
$baseTimestamp = $request['base_timestamp'] ?? 0;
if (!isset($request['auth_hash']) || !$this->hashVerification->verifyHash($request['auth_hash'], $baseTimestamp)) {
$this->logger->warning('Invalid authentication in verification request from @env', [
'@env' => $request['environment'] ?? 'unknown',
]);
throw new \RuntimeException($this->t('Invalid authentication')->render());
}
$lastChange = $this->state->get('config_preview_deploy.last_change', 0);
$environment = $request['environment'] ?? 'unknown';
// Deployment is valid if production hasn't changed since the base
// timestamp.
$isValid = ($lastChange <= $baseTimestamp);
$this->logger->debug('Verification request from @env: @status (prod: @prod_time, base: @base_time)', [
'@env' => $environment,
'@status' => $isValid ? 'valid' : 'invalid',
'@prod_time' => date('Y-m-d H:i:s', $lastChange),
'@base_time' => date('Y-m-d H:i:s', $baseTimestamp),
]);
return [
'current_timestamp' => $lastChange,
'last_deployed_from' => $this->state->get('config_preview_deploy.last_deployed_from', ''),
'valid' => $isValid,
];
}
/**
* Updates production's last change timestamp.
*
* This should be called after successful configuration deployment.
*
* @param string $environment
* The environment that deployed the configuration.
*/
public function updateLastChange(string $environment): void {
$timestamp = time();
$this->state->set('config_preview_deploy.last_change', $timestamp);
$this->state->set('config_preview_deploy.last_deployed_from', $environment);
}
/**
* Gets the current environment name.
*
* @return string
* The environment name.
*/
public function getEnvironmentName(): string {
// Try common environment variables used by hosting platforms.
$envVars = [
'PHAPP_ENV',
'LAGOON_ENVIRONMENT',
'PLATFORM_BRANCH',
'PANTHEON_ENVIRONMENT',
'ACQUIA_ENVIRONMENT',
'CI_ENVIRONMENT_SLUG',
];
foreach ($envVars as $var) {
$value = getenv($var);
if ($value !== FALSE && !empty($value)) {
return $value;
}
}
// Fallback to hostname or 'local'.
return gethostname() ?: 'local';
}
/**
* Deploys configuration diff to production.
*
* @param string $diff
* The configuration diff to deploy.
*
* @return array
* Response data from production deployment.
*
* @throws \RuntimeException
* When deployment fails or configuration is invalid.
*/
public function deployToProduction(string $diff): array {
$config = $this->configFactory->get('config_preview_deploy.settings');
$productionUrl = $config->get('production_url');
if (!$productionUrl) {
throw new \RuntimeException($this->t('Production URL not configured.')->render());
}
// Get OAuth access token from tempstore.
$tempstore = $this->privateTempStoreFactory->get('config_preview_deploy');
$access_token = $tempstore->get('access_token');
$token_type = $tempstore->get('token_type') ?? 'Bearer';
if (!$access_token) {
throw new \RuntimeException($this->t('No valid OAuth token found. Please authorize with production first.')->render());
}
// Create authentication hash including timestamp.
$productionHost = parse_url($productionUrl, PHP_URL_HOST);
$timestamp = time();
$authHash = $this->hashVerification->generateVerificationHash($productionHost, $timestamp);
$environment = $this->getEnvironmentName();
$deployUrl = rtrim($productionUrl, '/') . '/admin/config/development/config-preview-deploy/deploy-endpoint';
try {
$response = $this->httpClient->post($deployUrl, [
'json' => [
'diff' => $diff,
'environment' => $environment,
'auth_hash' => $authHash,
'timestamp' => $timestamp,
],
'timeout' => 60,
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'Authorization' => $token_type . ' ' . $access_token,
],
]);
$data = json_decode($response->getBody()->getContents(), TRUE);
if (!($data['success'] ?? FALSE)) {
throw new \RuntimeException($this->t('Deployment failed: @error', [
'@error' => $data['error'] ?? 'Unknown deployment error',
])->render());
}
// Update local state to reflect successful deployment.
$this->updateLastChange($environment);
return $data;
}
catch (\Exception $e) {
$this->logger->error('Failed to deploy to production: @message', [
'@message' => $e->getMessage(),
]);
throw new \RuntimeException($this->t('Failed to deploy to production: @message', ['@message' => $e->getMessage()])->render());
}
}
}
