config_preview_deploy-1.0.0-alpha3/src/Controller/PreviewController.php
src/Controller/PreviewController.php
<?php
declare(strict_types=1);
namespace Drupal\config_preview_deploy\Controller;
use Drupal\config_preview_deploy\Access\PreviewEnvironmentAccess;
use Drupal\config_preview_deploy\ConfigDiff;
use Drupal\config_preview_deploy\ConfigVerifier;
use Drupal\config_preview_deploy\HashVerification;
use Drupal\Core\Config\ConfigManagerInterface;
use Drupal\Core\Config\StorageInterface;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Diff\DiffFormatter;
use Drupal\Core\State\StateInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Response;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\RequestException;
/**
* Preview environment UI controller.
*/
class PreviewController extends ControllerBase {
/**
* The configuration diff service.
*/
protected ConfigDiff $configDiff;
/**
* The config verifier service.
*/
protected ConfigVerifier $configVerifier;
/**
* The state service.
*/
protected StateInterface $state;
/**
* The date formatter service.
*/
protected DateFormatterInterface $dateFormatter;
/**
* The config manager service.
*/
protected ConfigManagerInterface $configManager;
/**
* The diff formatter service.
*/
protected DiffFormatter $diffFormatter;
/**
* The preview environment access service.
*/
protected PreviewEnvironmentAccess $previewAccess;
/**
* The HTTP client service.
*/
protected ClientInterface $httpClient;
/**
* The config storage service.
*/
protected StorageInterface $configStorage;
/**
* The hash verification service.
*
* @var \Drupal\config_preview_deploy\HashVerification
*/
protected HashVerification $hashVerification;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
$instance = parent::create($container);
$instance->configDiff = $container->get('config_preview_deploy.config_diff');
$instance->configVerifier = $container->get('config_preview_deploy.config_verifier');
$instance->state = $container->get('state');
$instance->dateFormatter = $container->get('date.formatter');
$instance->configManager = $container->get('config.manager');
$instance->diffFormatter = $container->get('diff.formatter');
$instance->previewAccess = $container->get('config_preview_deploy.preview_environment_access');
$instance->httpClient = $container->get('http_client');
$instance->configStorage = $container->get('config.storage');
$instance->hashVerification = $container->get('config_preview_deploy.hash_verification');
return $instance;
}
/**
* Main dashboard for preview environment.
*
* @return array
* Render array for the dashboard.
*/
public function dashboard(): array {
$isProduction = $this->previewAccess->isProduction();
// Handle production environment dashboard.
if ($isProduction) {
return $this->buildProductionDashboard();
}
// Handle preview environment dashboard.
$baseVersion = $this->configDiff->getBaseVersion();
if (!$baseVersion) {
return [
'#markup' => '<div class="messages messages--warning">' .
$this->t('No base checkpoint found. Run <code>drush config:preview-deploy:init</code> after syncing from production.') .
'</div>',
];
}
$build = [
'#title' => $this->t('Configuration Preview Deploy'),
];
// Environment information.
$build['environment_info'] = [
'#type' => 'container',
'#attributes' => ['class' => ['config-preview-deploy-info']],
];
// Prepare environment info items.
$envItems = [
$this->t('Environment: @env', ['@env' => $this->getEnvironmentName()]),
$this->t('Base checkpoint: @date', ['@date' => $this->dateFormatter->format($baseVersion['timestamp'], 'custom', 'Y-m-d H:i:s')]),
];
// Add last change information for preview environments.
$this->addLastChangeInfo($envItems);
// Get production status for separate section.
$productionStatusResult = $this->getProductionStatus();
$productionStatus = $productionStatusResult['data'] ?? NULL;
$productionError = $productionStatusResult['error'] ?? NULL;
$rebaseRequired = FALSE;
$changeCount = 0;
try {
// Check for configuration changes.
// For display, show the filtered count (what will be deployed).
$changedConfigs = $this->configDiff->getChangedConfigs(TRUE);
$changeCount = count($changedConfigs);
// Add change count to environment info.
$envItems[] = $this->formatPlural($changeCount,
'Pending changes: 1 configuration',
'Pending changes: @count configurations'
);
}
catch (\Exception $e) {
$envItems[] = $this->t('Pending changes: Error checking changes');
}
$build['environment_info']['details'] = [
'#theme' => 'item_list',
'#title' => $this->t('Environment Information'),
'#items' => $envItems,
'#attributes' => ['class' => ['config-preview-deploy-details']],
];
// Production information section.
$build['production_info'] = [
'#type' => 'container',
'#attributes' => ['class' => ['config-preview-deploy-production-info']],
];
$prodItems = [];
if ($productionStatus) {
$prodLastChange = $productionStatus['last_change'];
$prodItems[] = $this->t('Production last changed: @date', [
'@date' => $this->dateFormatter->format($prodLastChange, 'custom', 'Y-m-d H:i:s'),
]);
// Check if rebase is required - compare production's last change with our
// base checkpoint.
if ($prodLastChange > $baseVersion['timestamp']) {
$rebaseRequired = TRUE;
$prodItems[] = $this->t('⚠️ Rebase required: Production has changed since base checkpoint');
}
else {
// Check if there are actually changes to deploy.
try {
// Use the same filtered change count we calculated above.
if ($changeCount > 0) {
$prodItems[] = $this->t('✅ Ready to deploy');
}
else {
$prodItems[] = $this->t('ℹ️ No changes to deploy');
}
}
catch (\Exception $e) {
$prodItems[] = $this->t('⚠️ Unable to check for changes');
}
}
}
else {
if ($productionError) {
$prodItems[] = $this->t('❌ Production status: @error', ['@error' => $productionError]);
}
else {
$prodItems[] = $this->t('❌ Production status: Unable to connect');
}
}
$build['production_info']['details'] = [
'#theme' => 'item_list',
'#title' => $this->t('Production Information'),
'#items' => $prodItems,
'#attributes' => ['class' => ['config-preview-deploy-production-details']],
];
try {
// Add action buttons.
// Use filtered change count to check for deployable changes.
$hasChanges = $changeCount > 0;
// Show actions if there are changes OR if rebase is required.
if ($hasChanges || $rebaseRequired) {
$build['actions'] = [
'#type' => 'actions',
'#attributes' => ['class' => ['config-preview-deploy-actions']],
];
// If rebase is required, always show rebase button as primary.
if ($rebaseRequired) {
$build['actions']['rebase'] = [
'#type' => 'link',
'#title' => $this->t('Rebase Environment'),
'#url' => Url::fromRoute('config_preview_deploy.rebase_form'),
'#attributes' => ['class' => ['button', 'button--primary']],
];
}
// Only show deploy button if there are changes and no rebase required.
if ($hasChanges && !$rebaseRequired) {
$build['actions']['deploy'] = [
'#type' => 'link',
'#title' => $this->t('Deploy to Production'),
'#url' => Url::fromRoute('config_preview_deploy.deploy_form'),
'#attributes' => [
'class' => ['button', 'button--primary'],
'id' => 'config-preview-deploy-button',
],
];
// Add rebase as secondary action.
$build['actions']['rebase'] = [
'#type' => 'link',
'#title' => $this->t('Rebase Environment'),
'#url' => Url::fromRoute('config_preview_deploy.rebase_form'),
'#attributes' => ['class' => ['button']],
];
}
// Show view changes button only if there are actual changes.
if ($hasChanges) {
$build['actions']['view_changes'] = [
'#type' => 'link',
'#title' => $this->t('View Changes'),
'#url' => Url::fromRoute('config_preview_deploy.changes'),
'#attributes' => ['class' => ['button']],
];
}
}
}
catch (\Exception $e) {
$build['error'] = [
'#markup' => '<div class="messages messages--error">' .
$this->t('Error checking configuration changes: @message', ['@message' => $e->getMessage()]) .
'</div>',
];
}
// JavaScript library disabled due to CORS issues.
// @todo Re-enable when cross-origin authentication is properly configured.
return $build;
}
/**
* Downloads the configuration diff as a file.
*
* @return \Symfony\Component\HttpFoundation\Response
* File download response.
*/
public function downloadDiff(): Response {
try {
$diff = $this->configDiff->generateDiff();
if (empty($diff)) {
$diff = $this->t('No configuration changes detected.')->render();
}
$filename = $this->t('config-diff-@env-@date.patch', [
'@env' => $this->getEnvironmentName(),
'@date' => date('Y-m-d-H-i-s'),
]);
$response = new Response($diff);
$response->headers->set('Content-Type', 'text/plain');
$response->headers->set('Content-Disposition', 'attachment; filename="' . $filename . '"');
return $response;
}
catch (\Exception $e) {
$this->messenger()->addError($this->t('Failed to generate diff: @message', ['@message' => $e->getMessage()]));
return $this->redirect('config_preview_deploy.dashboard');
}
}
/**
* Gets the current environment name.
*
* @return string
* The environment name.
*/
protected function getEnvironmentName(): string {
return $this->configVerifier->getEnvironmentName();
}
/**
* Builds the production environment dashboard.
*
* @return array
* Render array for production dashboard.
*/
protected function buildProductionDashboard(): array {
$build = [
'#title' => $this->t('Configuration Deploy Status (Production)'),
];
// Environment information for production.
$build['environment_info'] = [
'#type' => 'container',
'#attributes' => ['class' => ['config-preview-deploy-info']],
];
$envItems = [
$this->t('Environment: Production'),
];
// Add last change information.
$this->addLastChangeInfo($envItems);
$build['environment_info']['details'] = [
'#theme' => 'item_list',
'#title' => $this->t('Environment Information'),
'#items' => $envItems,
'#attributes' => ['class' => ['config-preview-deploy-details']],
];
$build['production_notice'] = [
'#markup' => '<div class="messages messages--status">' .
$this->t('This is the production environment. Configuration deployments are received from preview environments.') .
'</div>',
];
return $build;
}
/**
* Adds last change information to environment items.
*
* @param array &$envItems
* Array of environment information items to add to.
*/
protected function addLastChangeInfo(array &$envItems): void {
// Get last configuration change timestamp.
$lastChange = $this->state->get('config_preview_deploy.last_change', 0);
if ($lastChange > 0) {
$lastChangeDate = $this->dateFormatter->format($lastChange, 'custom', 'Y-m-d H:i:s');
$envItems[] = $this->t('Last configuration change: @date', [
'@date' => $lastChangeDate,
]);
// Calculate time since last change using Drupal's helper.
$timeSince = time() - $lastChange;
$timeString = $this->dateFormatter->formatInterval($timeSince);
$envItems[] = $this->t('Time since last change: @time ago', [
'@time' => $timeString,
]);
}
else {
$envItems[] = $this->t('Last configuration change: No changes tracked yet');
}
}
/**
* Shows the changes page with list of modified configurations.
*
* @return array
* Render array for the changes page.
*/
public function changes(): array {
$build = [
'#title' => $this->t('Configuration Changes'),
];
try {
// Check if base version exists first.
$baseVersion = $this->configDiff->getBaseVersion();
if (!$baseVersion) {
$build['no_changes'] = [
'#markup' => '<div class="messages messages--warning">' .
$this->t('No base checkpoint found. Run <code>drush config:preview-deploy:init</code> after syncing from production.') .
'</div>',
];
return $build;
}
// Check for configuration changes using filtered list.
$changedConfigs = $this->configDiff->getChangedConfigs(TRUE);
if (!empty($changedConfigs)) {
// Build table of changed configurations.
$header = [
$this->t('Configuration'),
$this->t('Operations'),
];
$rows = [];
foreach ($changedConfigs as $configName) {
$rows[] = [
'data' => [
$configName,
[
'data' => [
'#type' => 'link',
'#title' => $this->t('View differences'),
'#url' => Url::fromRoute('config_preview_deploy.diff', ['config_name' => $configName]),
'#attributes' => [
'class' => ['use-ajax'],
'data-dialog-type' => 'modal',
'data-dialog-options' => json_encode([
'width' => 700,
]),
],
],
],
],
];
}
$build['changes_table'] = [
'#type' => 'table',
'#header' => $header,
'#rows' => $rows,
'#empty' => $this->t('No configuration changes detected.'),
'#attributes' => ['class' => ['config-preview-deploy-changes-table']],
];
$build['#attached']['library'][] = 'core/drupal.dialog.ajax';
// Add download button at the bottom.
$build['download'] = [
'#type' => 'actions',
'#attributes' => ['class' => ['config-preview-deploy-actions']],
];
$build['download']['download_diff'] = [
'#type' => 'link',
'#title' => $this->t('Download Complete Diff'),
'#url' => Url::fromRoute('config_preview_deploy.download_diff'),
'#attributes' => ['class' => ['button']],
];
}
else {
$build['no_changes'] = [
'#markup' => '<div class="messages messages--status">' .
$this->t('No configuration changes detected since base sync.') .
'</div>',
];
}
}
catch (\Exception $e) {
$build['error'] = [
'#markup' => '<div class="messages messages--error">' .
$this->t('Error checking configuration changes: @message', ['@message' => $e->getMessage()]) .
'</div>',
];
}
return $build;
}
/**
* Shows diff of specified configuration file.
*
* @param string $config_name
* The name of the configuration file.
*
* @return array
* Table showing a two-way diff between the base and current configuration.
*/
public function diff(string $config_name): array {
try {
// Check if base version exists.
$baseVersion = $this->configDiff->getBaseVersion();
if (!$baseVersion) {
return [
'#markup' => '<div class="messages messages--error">' .
$this->t('No base checkpoint found.') .
'</div>',
];
}
// Get the checkpoint storage from ConfigurationDiff.
$checkpointId = $baseVersion['checkpoint_id'];
$checkpointStorage = $this->configDiff->getCheckpointStorage();
$checkpointStorage->setCheckpointToReadFrom($checkpointId);
// Get current config storage.
$activeStorage = $this->configStorage;
// Generate diff using config manager.
$diff = $this->configManager->diff($checkpointStorage, $activeStorage, $config_name);
// Disable header for inline display.
$this->diffFormatter->show_header = FALSE;
$build = [];
$build['#title'] = $this->t('View changes of @config_file', ['@config_file' => $config_name]);
// Add the CSS for the inline diff.
$build['#attached']['library'][] = 'system/diff';
$build['diff'] = [
'#type' => 'table',
'#attributes' => [
'class' => ['diff'],
],
'#header' => [
['data' => $this->t('Base'), 'colspan' => '2'],
['data' => $this->t('Current'), 'colspan' => '2'],
],
'#rows' => $this->diffFormatter->format($diff),
];
$build['back'] = [
'#type' => 'link',
'#attributes' => [
'class' => [
'dialog-cancel',
],
],
'#title' => $this->t('Back to Changes'),
'#url' => Url::fromRoute('config_preview_deploy.changes'),
];
return $build;
}
catch (\Exception $e) {
return [
'#markup' => '<div class="messages messages--error">' .
$this->t('Error generating diff for @config: @message', [
'@config' => $config_name,
'@message' => $e->getMessage(),
]) .
'</div>',
];
}
}
/**
* Gets production configuration status via API call.
*
* @return array
* Array with 'data' (production status) and 'error' (error message) keys.
*/
protected function getProductionStatus(): array {
$config = $this->config('config_preview_deploy.settings');
$productionUrl = $config->get('production_url');
if (!$productionUrl) {
return ['data' => NULL, 'error' => 'Production URL not configured'];
}
try {
// Generate token for status API.
$timestamp = time();
$productionHost = parse_url($productionUrl, PHP_URL_HOST);
$token = $this->hashVerification->generateVerificationHash($productionHost, $timestamp);
// Make API call to production status endpoint.
$statusUrl = rtrim($productionUrl, '/') . '/api/config-preview-deploy/status';
$response = $this->httpClient->get($statusUrl, [
'query' => [
'timestamp' => $timestamp,
'token' => $token,
],
'timeout' => 10,
]);
$data = json_decode($response->getBody()->getContents(), TRUE);
if (is_array($data) && isset($data['last_change'])) {
// Log the API call and result at debug level.
$this->getLogger('config_preview_deploy')->debug('Production API call to @url returned last change: @last_change', [
'@url' => $statusUrl,
'@last_change' => !empty($data['last_change']) ? $this->dateFormatter->format($data['last_change'], 'short') : 'never',
]);
return ['data' => $data, 'error' => NULL];
}
else {
return ['data' => NULL, 'error' => 'Invalid response format from production'];
}
}
catch (RequestException $e) {
// Log error but don't break the dashboard.
$this->getLogger('config_preview_deploy')->warning('Failed to get production status: @message', [
'@message' => $e->getMessage(),
]);
// Determine if this is a network connectivity issue or authentication
// issue.
$code = $e->getCode();
if ($code >= 400 && $code < 500) {
return ['data' => NULL, 'error' => 'Authentication failed (HTTP ' . $code . ')'];
}
elseif ($code >= 500) {
return ['data' => NULL, 'error' => 'Production server error (HTTP ' . $code . ')'];
}
else {
return ['data' => NULL, 'error' => 'Network connectivity issue'];
}
}
catch (\Exception $e) {
$this->getLogger('config_preview_deploy')->warning('Error getting production status: @message', [
'@message' => $e->getMessage(),
]);
return ['data' => NULL, 'error' => 'Connection failed: ' . $e->getMessage()];
}
}
}
