config_preview_deploy-1.0.0-alpha3/src/ConfigRebaser.php
src/ConfigRebaser.php
<?php
declare(strict_types=1);
namespace Drupal\config_preview_deploy;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\FileStorage;
use Drupal\Core\Config\StorageInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\State\StateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use Drupal\Core\Archiver\ArchiveTar;
use Drupal\Core\Config\ConfigImporterException;
/**
* Rebases configuration on production.
*/
class ConfigRebaser {
use StringTranslationTrait;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected ConfigFactoryInterface $configFactory;
/**
* The configuration diff service.
*
* @var \Drupal\config_preview_deploy\ConfigDiff
*/
protected ConfigDiff $configDiff;
/**
* The file system service.
*
* @var \Drupal\Core\File\FileSystemInterface
*/
protected FileSystemInterface $fileSystem;
/**
* The logger channel factory.
*
* @var \Drupal\Core\Logger\LoggerChannelFactoryInterface
*/
protected LoggerChannelFactoryInterface $loggerFactory;
/**
* The state service.
*
* @var \Drupal\Core\State\StateInterface
*/
protected StateInterface $state;
/**
* The HTTP client.
*
* @var \GuzzleHttp\ClientInterface
*/
protected ClientInterface $httpClient;
/**
* The hash verification service.
*
* @var \Drupal\config_preview_deploy\HashVerification
*/
protected HashVerification $hashVerification;
/**
* The production config importer service.
*
* @var \Drupal\config_preview_deploy\ProductionConfigImporter
*/
protected ProductionConfigImporter $productionConfigImporter;
/**
* The private tempstore factory.
*
* @var \Drupal\Core\TempStore\PrivateTempStoreFactory
*/
protected PrivateTempStoreFactory $privateTempStoreFactory;
/**
* The config storage.
*
* @var \Drupal\Core\Config\StorageInterface
*/
protected StorageInterface $configStorage;
/**
* Constructs a ConfigRebaser.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\config_preview_deploy\ConfigDiff $config_diff
* The configuration diff service.
* @param \Drupal\Core\File\FileSystemInterface $file_system
* The file system service.
* @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
* The logger channel factory.
* @param \Drupal\Core\State\StateInterface $state
* The state service.
* @param \GuzzleHttp\ClientInterface $http_client
* The HTTP client.
* @param \Drupal\config_preview_deploy\HashVerification $hash_verification
* The hash verification service.
* @param \Drupal\config_preview_deploy\ProductionConfigImporter $production_config_importer
* The production config importer service.
* @param \Drupal\Core\TempStore\PrivateTempStoreFactory $private_tempstore_factory
* The private tempstore factory.
* @param \Drupal\Core\Config\StorageInterface $config_storage
* The config storage.
*/
public function __construct(
ConfigFactoryInterface $config_factory,
ConfigDiff $config_diff,
FileSystemInterface $file_system,
LoggerChannelFactoryInterface $logger_factory,
StateInterface $state,
ClientInterface $http_client,
HashVerification $hash_verification,
ProductionConfigImporter $production_config_importer,
PrivateTempStoreFactory $private_tempstore_factory,
StorageInterface $config_storage,
) {
$this->configFactory = $config_factory;
$this->configDiff = $config_diff;
$this->fileSystem = $file_system;
$this->loggerFactory = $logger_factory;
$this->state = $state;
$this->httpClient = $http_client;
$this->hashVerification = $hash_verification;
$this->productionConfigImporter = $production_config_importer;
$this->privateTempStoreFactory = $private_tempstore_factory;
$this->configStorage = $config_storage;
}
/**
* Rebases configuration on production.
*
* @param string|null $tarball_path
* Optional path to a production config tarball. If not provided,
* the config will be fetched from production via OAuth.
*
* @return array
* An array with the following keys:
* - success: Boolean indicating if rebase was successful.
* - message: Human-readable message about the result.
* - conflicts: Array of conflict information if any.
* - conflicts_file: Path to conflicts file if conflicts occurred.
*
* @throws \Exception
* If rebase fails catastrophically.
*/
public function rebase(?string $tarball_path = NULL): array {
$tempDir = $this->fileSystem->getTempDirectory() . '/config-rebase-' . uniqid();
$this->fileSystem->mkdir($tempDir, 0777);
try {
// Step 1: Save current changes if any.
$savedDiff = NULL;
$diffPath = NULL;
if ($this->configDiff->hasChanges()) {
// Generate diff without transformations to preserve all configs
// during rebase.
$savedDiff = $this->configDiff->generateDiff(FALSE);
if ($savedDiff) {
$diffPath = $tempDir . '/saved-changes.patch';
file_put_contents($diffPath, $savedDiff);
$this->loggerFactory->get('config_preview_deploy')->notice('Saved current configuration changes before rebase.');
}
}
// Step 2: Get production config.
if ($tarball_path && file_exists($tarball_path)) {
// Use provided tarball.
$configDir = $this->extractTarball($tarball_path, $tempDir);
$this->loggerFactory->get('config_preview_deploy')->notice('Using provided tarball for rebase: @path', ['@path' => $tarball_path]);
}
else {
// Fetch from production via OAuth.
$configDir = $this->fetchProductionConfig($tempDir);
$this->loggerFactory->get('config_preview_deploy')->notice('Fetched production configuration via OAuth.');
}
// Step 3: Create checkpoint before importing production config.
$preImportCheckpoint = $this->configDiff->getCheckpointStorage()->checkpoint(
$this->t('Before rebase import - @date', ['@date' => date('Y-m-d H:i:s')])
);
$this->loggerFactory->get('config_preview_deploy')->notice('Created checkpoint before import: @id', ['@id' => $preImportCheckpoint->id]);
// Step 4: Import production config.
$this->importConfig($configDir);
$this->loggerFactory->get('config_preview_deploy')->notice('Imported production configuration.');
// Step 4: Re-initialize checkpoint with production state.
$checkpointId = $this->configDiff->createBaseCheckpoint();
$this->state->set('config_preview_deploy.base_checkpoint_id', $checkpointId);
$this->state->set('config_preview_deploy.base_checkpoint_timestamp', time());
$this->loggerFactory->get('config_preview_deploy')->notice('Created new base checkpoint: @id', ['@id' => $checkpointId]);
// Step 5: Re-apply saved diff if any.
if ($savedDiff) {
$result = $this->reapplyDiff($savedDiff, $tempDir);
if (!$result['success']) {
$this->loggerFactory->get('config_preview_deploy')->warning('Rebase completed but conflicts were found when re-applying changes.');
return [
'success' => FALSE,
'message' => $this->t('Rebase completed but conflicts were found when re-applying changes.'),
'conflicts' => $result['conflicts'],
'conflicts_file' => $diffPath,
];
}
$this->loggerFactory->get('config_preview_deploy')->notice('Successfully re-applied saved changes after rebase.');
}
// Clean up.
$this->fileSystem->deleteRecursive($tempDir);
return [
'success' => TRUE,
'message' => $this->t('Configuration successfully rebased on production.'),
'conflicts' => [],
'conflicts_file' => NULL,
];
}
catch (\Exception $e) {
// Clean up on error.
if (file_exists($tempDir)) {
$this->fileSystem->deleteRecursive($tempDir);
}
$this->loggerFactory->get('config_preview_deploy')->error('Rebase failed: @message', ['@message' => $e->getMessage()]);
throw $e;
}
}
/**
* Fetches production configuration via OAuth.
*
* @param string $temp_dir
* Temporary directory for storing files.
*
* @return string
* Path to extracted config directory.
*
* @throws \Exception
* If fetch fails.
*/
protected function fetchProductionConfig(string $temp_dir): string {
// Get OAuth access token from tempstore.
$tempstore = $this->privateTempStoreFactory->get('config_preview_deploy');
$access_token = $tempstore->get('access_token');
if (empty($access_token)) {
throw new \Exception((string) $this->t('No OAuth token available. Please authorize with production first.'));
}
// Check if token is expired (240 seconds = 4 minutes).
$tokenReceivedAt = $tempstore->get('token_received_at');
// 4 minutes
$maxAge = 240;
if (!$tokenReceivedAt || (time() - $tokenReceivedAt) > $maxAge) {
throw new \Exception((string) $this->t('OAuth token has expired. Please re-authorize with production.'));
}
// Get production URL.
$config = $this->configFactory->get('config_preview_deploy.settings');
$productionUrl = $config->get('production_url');
if (!$productionUrl) {
throw new \Exception((string) $this->t('Production URL not configured.'));
}
// Generate authentication hash.
$timestamp = time();
$host = parse_url($productionUrl, PHP_URL_HOST);
$hash = $this->hashVerification->generateVerificationHash($host, $timestamp);
// Fetch config export from production.
try {
$response = $this->httpClient->request('GET', $productionUrl . '/api/config-preview-deploy/export', [
'headers' => [
'Authorization' => 'Bearer ' . $access_token,
'X-Config-Deploy-Hash' => $hash,
'X-Config-Deploy-Timestamp' => (string) $timestamp,
],
'timeout' => 60,
]);
if ($response->getStatusCode() !== 200) {
throw new \Exception((string) $this->t('Failed to fetch production config: HTTP @code', ['@code' => $response->getStatusCode()]));
}
// Save tarball.
$tarballPath = $temp_dir . '/production-config.tar.gz';
file_put_contents($tarballPath, $response->getBody()->getContents());
// Extract and return config directory.
return $this->extractTarball($tarballPath, $temp_dir);
}
catch (GuzzleException $e) {
throw new \Exception((string) $this->t('Failed to fetch production config: @message', ['@message' => $e->getMessage()]));
}
}
/**
* Extracts a config tarball.
*
* @param string $tarball_path
* Path to the tarball.
* @param string $temp_dir
* Temporary directory.
*
* @return string
* Path to extracted config directory.
*
* @throws \Exception
* If extraction fails.
*/
protected function extractTarball(string $tarball_path, string $temp_dir): string {
$extractDir = $temp_dir . '/config';
$this->fileSystem->mkdir($extractDir, 0777);
$archiver = new ArchiveTar($tarball_path, 'gz');
if (!$archiver->extract($extractDir)) {
throw new \Exception((string) $this->t('Failed to extract config tarball.'));
}
// Verify it's a valid config export.
$files = glob($extractDir . '/*.yml');
if (empty($files)) {
throw new \Exception((string) $this->t('Invalid config tarball: no YAML files found.'));
}
return $extractDir;
}
/**
* Imports configuration from a directory.
*
* @param string $config_dir
* Path to config directory.
*
* @throws \Exception
* If import fails.
*/
protected function importConfig(string $config_dir): void {
// Create file storage for the config directory.
$sourceStorage = new FileStorage($config_dir);
// Get active storage.
$activeStorage = $this->configStorage;
// Create config importer using the production config importer service.
$configImporter = $this->productionConfigImporter->createImporter($sourceStorage, $activeStorage);
// Check if there are changes to import.
if (!$configImporter->getStorageComparer()->hasChanges()) {
$this->loggerFactory->get('config_preview_deploy')->notice('No configuration changes to import during rebase.');
return;
}
try {
// Validate the import.
$configImporter->validate();
// Import the configuration.
$configImporter->import();
}
catch (ConfigImporterException $e) {
throw new \Exception((string) $this->t('Configuration import failed: @message', ['@message' => $e->getMessage()]));
}
}
/**
* Re-applies a saved diff after rebase.
*
* @param string $diff
* The diff to apply.
* @param string $temp_dir
* Temporary directory.
*
* @return array
* Result array with success status and conflict information.
*/
protected function reapplyDiff(string $diff, string $temp_dir): array {
try {
// Use the same diff application logic as production deployment.
$results = $this->configDiff->validateAndApplyDiff($diff);
$this->loggerFactory->get('config_preview_deploy')->notice('Successfully re-applied @count configuration changes after rebase.', [
'@count' => count($results),
]);
return [
'success' => TRUE,
'conflicts' => [],
'conflicts_file' => NULL,
];
}
catch (\Exception $e) {
$this->loggerFactory->get('config_preview_deploy')->warning('Failed to re-apply changes after rebase: @message', [
'@message' => $e->getMessage(),
]);
return [
'success' => FALSE,
'conflicts' => [
[
'file' => 'saved-changes.patch',
'error' => $e->getMessage(),
],
],
];
}
}
}
