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(),
          ],
        ],
      ];
    }
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc