From f8849aba7335a43bc6b860baf4215e7175ceec07 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 13 May 2026 09:58:58 +0200 Subject: [PATCH 1/3] feat(deployments): track application configuration diffs Store deployment configuration snapshots on application deployment queues and compare them against the current application state. Surface grouped pending changes in the configuration checker and use build-impact diffs to decide when an existing image can skip the build step. --- app/Jobs/ApplicationDeploymentJob.php | 18 +- .../Project/Shared/ConfigurationChecker.php | 15 + app/Models/Application.php | 116 ++++-- app/Models/ApplicationDeploymentQueue.php | 8 + .../ApplicationConfigurationSnapshot.php | 338 ++++++++++++++++++ .../ConfigurationDiff.php | 112 ++++++ .../ConfigurationDiffer.php | 69 ++++ ...to_application_deployment_queues_table.php | 28 ++ openapi.json | 12 + openapi.yaml | 9 + .../deployment/configuration-diff.blade.php | 70 ++++ resources/views/layouts/app.blade.php | 2 - ...ub-private-repository-deploy-key.blade.php | 8 - .../new/github-private-repository.blade.php | 8 - .../new/public-git-repository.blade.php | 8 - .../shared/configuration-checker.blade.php | 90 ++++- tests/Feature/Api/RailpackApiTest.php | 3 +- .../ApplicationBuildpackCleanupTest.php | 21 ++ .../ApplicationConfigurationChangedTest.php | 70 ++++ ...pplicationGeneralBuildpackSelectorTest.php | 5 +- .../Livewire/ConfigurationCheckerTest.php | 119 ++++++ .../NewApplicationBuildpackDefaultsTest.php | 8 +- .../ApplicationConfigurationSnapshotTest.php | 123 +++++++ 23 files changed, 1171 insertions(+), 89 deletions(-) create mode 100644 app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php create mode 100644 app/Services/DeploymentConfiguration/ConfigurationDiff.php create mode 100644 app/Services/DeploymentConfiguration/ConfigurationDiffer.php create mode 100644 database/migrations/2026_05_11_000000_add_configuration_snapshot_to_application_deployment_queues_table.php create mode 100644 resources/views/components/deployment/configuration-diff.blade.php create mode 100644 tests/Feature/ApplicationConfigurationChangedTest.php create mode 100644 tests/Feature/Livewire/ConfigurationCheckerTest.php create mode 100644 tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index c2be064c4..2e43456b8 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -537,11 +537,6 @@ private function post_deployment() \Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage()); } - try { - $this->application->isConfigurationChanged(true); - } catch (Exception $e) { - \Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage()); - } } private function deploy_simple_dockerfile() @@ -1238,8 +1233,9 @@ private function should_skip_build() return true; } - if (! $this->application->isConfigurationChanged()) { - $this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); + $configurationDiff = $this->application->pendingDeploymentConfigurationDiff(); + if (! $configurationDiff->requiresBuild()) { + $this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); $this->skip_build = true; $this->generate_compose_file(); @@ -1251,7 +1247,7 @@ private function should_skip_build() return true; } else { - $this->application_deployment_queue->addLogEntry('Configuration changed. Rebuilding image.'); + $this->application_deployment_queue->addLogEntry('Build configuration changed. Rebuilding image.'); } } else { $this->application_deployment_queue->addLogEntry("Image not found ({$this->production_image_name}). Building new image."); @@ -4738,6 +4734,12 @@ private function handleSuccessfulDeployment(): void 'last_restart_type' => null, ]); + try { + $this->application->markDeploymentConfigurationApplied($this->application_deployment_queue); + } catch (Exception $e) { + \Log::warning('Failed to mark configuration as applied for deployment '.$this->deployment_uuid.': '.$e->getMessage()); + } + event(new ApplicationConfigurationChanged($this->application->team()->id)); if (! $this->only_this_server) { diff --git a/app/Livewire/Project/Shared/ConfigurationChecker.php b/app/Livewire/Project/Shared/ConfigurationChecker.php index ce9ce7780..1bf2ecada 100644 --- a/app/Livewire/Project/Shared/ConfigurationChecker.php +++ b/app/Livewire/Project/Shared/ConfigurationChecker.php @@ -18,6 +18,10 @@ class ConfigurationChecker extends Component { public bool $isConfigurationChanged = false; + public array $configurationDiff = []; + + public array $groupedConfigurationChanges = []; + public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource; public function getListeners() @@ -42,6 +46,17 @@ public function render() public function configurationChanged() { + if ($this->resource instanceof Application) { + $diff = $this->resource->pendingDeploymentConfigurationDiff(); + $this->isConfigurationChanged = $diff->isChanged(); + $this->configurationDiff = $diff->toArray(); + $this->groupedConfigurationChanges = $diff->groupedChanges(); + + return; + } + $this->isConfigurationChanged = $this->resource->isConfigurationChanged(); + $this->configurationDiff = []; + $this->groupedConfigurationChanges = []; } } diff --git a/app/Models/Application.php b/app/Models/Application.php index b5a0f0ea9..0c43f6c3f 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -4,6 +4,9 @@ use App\Enums\ApplicationDeploymentStatus; use App\Services\ConfigurationGenerator; +use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot; +use App\Services\DeploymentConfiguration\ConfigurationDiff; +use App\Services\DeploymentConfiguration\ConfigurationDiffer; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; use App\Traits\HasMetrics; @@ -720,14 +723,14 @@ public function dockerfileLocation(): Attribute return Attribute::make( set: function ($value) { if (is_null($value) || $value === '') { - return '/Dockerfile'; - } else { - if ($value !== '/') { - return Str::start(Str::replaceEnd('/', '', $value), '/'); - } - - return Str::start($value, '/'); + return $this->build_pack === 'dockerfile' ? '/Dockerfile' : null; } + + if ($value !== '/') { + return Str::start(Str::replaceEnd('/', '', $value), '/'); + } + + return Str::start($value, '/'); } ); } @@ -1059,7 +1062,7 @@ public function isDeploymentInprogress() public function get_last_successful_deployment() { - return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first(); + return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED->value)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first(); } public function get_last_days_deployments() @@ -1170,6 +1173,83 @@ public function isLogDrainEnabled() } public function isConfigurationChanged(bool $save = false) + { + $configurationDiff = $this->pendingDeploymentConfigurationDiff(); + + if ($save) { + $this->markDeploymentConfigurationApplied(); + } + + return $configurationDiff->isChanged(); + } + + public function pendingDeploymentConfigurationDiff(): ConfigurationDiff + { + $currentSnapshot = $this->deploymentConfigurationSnapshot(); + $lastDeployment = $this->get_last_successful_deployment(); + + if ($lastDeployment?->configuration_snapshot) { + return app(ConfigurationDiffer::class)->diff($lastDeployment->configuration_snapshot, $currentSnapshot); + } + + $oldConfigHash = data_get($this, 'config_hash'); + + if ($oldConfigHash === null) { + return ConfigurationDiff::legacy(true); + } + + return ConfigurationDiff::legacy($oldConfigHash !== $this->legacyConfigurationHash()); + } + + public function hasPendingDeploymentConfigurationChanges(): bool + { + return $this->pendingDeploymentConfigurationDiff()->isChanged(); + } + + public function deploymentConfigurationSnapshot(): array + { + return (new ApplicationConfigurationSnapshot($this))->toArray(); + } + + public function deploymentConfigurationHash(): string + { + return ApplicationConfigurationSnapshot::hashSnapshot($this->deploymentConfigurationSnapshot()); + } + + public function markDeploymentConfigurationApplied(?ApplicationDeploymentQueue $deployment = null): void + { + $this->refresh(); + + if (! $deployment) { + $this->forceFill(['config_hash' => $this->legacyConfigurationHash()])->save(); + + return; + } + + $snapshot = $this->deploymentConfigurationSnapshot(); + $hash = ApplicationConfigurationSnapshot::hashSnapshot($snapshot); + + $previousDeployment = ApplicationDeploymentQueue::query() + ->where('application_id', $this->id) + ->where('status', ApplicationDeploymentStatus::FINISHED->value) + ->where('pull_request_id', $deployment->pull_request_id ?? 0) + ->where('id', '!=', $deployment->id) + ->whereNotNull('configuration_snapshot') + ->latest() + ->first(); + + $deployment->update([ + 'configuration_hash' => $hash, + 'configuration_snapshot' => $snapshot, + 'configuration_diff' => $previousDeployment?->configuration_snapshot + ? app(ConfigurationDiffer::class)->diff($previousDeployment->configuration_snapshot, $snapshot)->toArray() + : null, + ]); + + $this->forceFill(['config_hash' => $hash])->save(); + } + + private function legacyConfigurationHash(): string { $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build); if ($this->pull_request_id === 0 || $this->pull_request_id === null) { @@ -1177,26 +1257,8 @@ public function isConfigurationChanged(bool $save = false) } else { $newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } - $newConfigHash = md5($newConfigHash); - $oldConfigHash = data_get($this, 'config_hash'); - if ($oldConfigHash === null) { - if ($save) { - $this->config_hash = $newConfigHash; - $this->save(); - } - return true; - } - if ($oldConfigHash === $newConfigHash) { - return false; - } else { - if ($save) { - $this->config_hash = $newConfigHash; - $this->save(); - } - - return true; - } + return md5($newConfigHash); } public function customRepository() diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 67f28523c..afac89fa8 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -17,6 +17,9 @@ 'deployment_uuid' => ['type' => 'string'], 'pull_request_id' => ['type' => 'integer'], 'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true], + 'configuration_hash' => ['type' => 'string', 'nullable' => true], + 'configuration_snapshot' => ['type' => 'object', 'nullable' => true], + 'configuration_diff' => ['type' => 'object', 'nullable' => true], 'force_rebuild' => ['type' => 'boolean'], 'commit' => ['type' => 'string'], 'status' => ['type' => 'string'], @@ -45,6 +48,9 @@ class ApplicationDeploymentQueue extends Model 'deployment_uuid', 'pull_request_id', 'docker_registry_image_tag', + 'configuration_hash', + 'configuration_snapshot', + 'configuration_diff', 'force_rebuild', 'commit', 'status', @@ -71,6 +77,8 @@ class ApplicationDeploymentQueue extends Model protected $casts = [ 'pull_request_id' => 'integer', 'finished_at' => 'datetime', + 'configuration_snapshot' => 'array', + 'configuration_diff' => 'array', ]; public function application() diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php new file mode 100644 index 000000000..676b22b6c --- /dev/null +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -0,0 +1,338 @@ + + */ + public function toArray(): array + { + $this->application->load('settings'); + + return [ + 'schema_version' => self::SCHEMA_VERSION, + 'resource_type' => Application::class, + 'resource_id' => $this->application->id, + 'sections' => [ + 'source' => [ + 'label' => 'Source', + 'items' => $this->sourceItems(), + ], + 'build' => [ + 'label' => 'Build', + 'items' => $this->buildItems(), + ], + 'runtime' => [ + 'label' => 'Runtime', + 'items' => $this->runtimeItems(), + ], + 'domains' => [ + 'label' => 'Domains & Proxy', + 'items' => $this->domainItems(), + ], + 'environment' => [ + 'label' => 'Environment Variables', + 'items' => $this->environmentItems(), + ], + ], + ]; + } + + public function hash(): string + { + return self::hashSnapshot($this->toArray()); + } + + /** + * @param array $snapshot + */ + public static function hashSnapshot(array $snapshot): string + { + return hash('sha256', json_encode(self::comparableSnapshot($snapshot), JSON_THROW_ON_ERROR)); + } + + /** + * @param array $snapshot + * @return array + */ + public static function comparableSnapshot(array $snapshot): array + { + $sections = collect(data_get($snapshot, 'sections', [])) + ->mapWithKeys(function (array $section, string $sectionKey): array { + $items = collect(data_get($section, 'items', [])) + ->mapWithKeys(fn (array $item): array => [ + $item['key'] => [ + 'compare_value' => $item['compare_value'] ?? null, + 'impact' => $item['impact'] ?? 'redeploy', + ], + ]) + ->sortKeys() + ->all(); + + return [$sectionKey => $items]; + }) + ->sortKeys() + ->all(); + + return [ + 'schema_version' => data_get($snapshot, 'schema_version'), + 'sections' => $sections, + ]; + } + + /** + * @return array> + */ + private function sourceItems(): array + { + return [ + $this->item('git_repository', 'Repository', $this->application->git_repository, 'build'), + $this->item('git_branch', 'Branch', $this->application->git_branch, 'build'), + $this->item('git_commit_sha', 'Commit SHA', $this->application->git_commit_sha, 'build'), + $this->item('private_key_id', 'Private key', $this->application->private_key_id, 'build'), + ]; + } + + /** + * @return array> + */ + private function buildItems(): array + { + return [ + $this->item('build_pack', 'Build pack', $this->application->build_pack, 'build'), + $this->item('static_image', 'Static image', $this->application->static_image, 'build'), + $this->item('base_directory', 'Base directory', $this->application->base_directory, 'build'), + $this->item('publish_directory', 'Publish directory', $this->application->publish_directory, 'build'), + $this->item('install_command', 'Install command', $this->application->install_command, 'build'), + $this->item('build_command', 'Build command', $this->application->build_command, 'build'), + $this->item('dockerfile', 'Dockerfile', $this->application->dockerfile, 'build', displayValue: $this->summarizeText($this->application->dockerfile)), + $this->item('dockerfile_location', 'Dockerfile location', $this->application->dockerfile_location, 'build'), + $this->item('dockerfile_target_build', 'Dockerfile target', $this->application->dockerfile_target_build, 'build'), + $this->item('docker_compose_location', 'Docker Compose location', $this->application->docker_compose_location, 'build'), + $this->item('docker_compose', 'Docker Compose', $this->application->docker_compose, 'build', displayValue: $this->summarizeText($this->application->docker_compose)), + $this->item('docker_compose_raw', 'Raw Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw)), + $this->item('docker_compose_custom_build_command', 'Docker Compose custom build command', $this->application->docker_compose_custom_build_command, 'build'), + $this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'build'), + $this->item('use_build_secrets', 'Use build secrets', data_get($this->application, 'settings.use_build_secrets'), 'build'), + $this->item('inject_build_args_to_dockerfile', 'Inject build args to Dockerfile', data_get($this->application, 'settings.inject_build_args_to_dockerfile'), 'build'), + $this->item('include_source_commit_in_build', 'Include source commit in build', data_get($this->application, 'settings.include_source_commit_in_build'), 'build'), + $this->item('disable_build_cache', 'Disable build cache', data_get($this->application, 'settings.disable_build_cache'), 'build'), + $this->item('is_build_server_enabled', 'Build server', data_get($this->application, 'settings.is_build_server_enabled'), 'build'), + ]; + } + + /** + * @return array> + */ + private function runtimeItems(): array + { + return [ + $this->item('start_command', 'Start command', $this->application->start_command, 'redeploy'), + $this->item('docker_compose_custom_start_command', 'Docker Compose custom start command', $this->application->docker_compose_custom_start_command, 'redeploy'), + $this->item('ports_exposes', 'Exposed ports', $this->application->ports_exposes, 'redeploy'), + $this->item('ports_mappings', 'Port mappings', $this->application->ports_mappings, 'redeploy'), + $this->item('custom_network_aliases', 'Network aliases', $this->application->custom_network_aliases, 'redeploy'), + $this->item('connect_to_docker_network', 'Connect to Docker network', data_get($this->application, 'settings.connect_to_docker_network'), 'redeploy'), + $this->item('custom_internal_name', 'Custom container name', data_get($this->application, 'settings.custom_internal_name'), 'redeploy'), + $this->item('is_raw_compose_deployment_enabled', 'Raw Compose deployment', data_get($this->application, 'settings.is_raw_compose_deployment_enabled'), 'redeploy'), + $this->item('is_gpu_enabled', 'GPU enabled', data_get($this->application, 'settings.is_gpu_enabled'), 'redeploy'), + $this->item('gpu_driver', 'GPU driver', data_get($this->application, 'settings.gpu_driver'), 'redeploy'), + $this->item('gpu_count', 'GPU count', data_get($this->application, 'settings.gpu_count'), 'redeploy'), + $this->item('gpu_device_ids', 'GPU device IDs', data_get($this->application, 'settings.gpu_device_ids'), 'redeploy'), + $this->item('gpu_options', 'GPU options', data_get($this->application, 'settings.gpu_options'), 'redeploy'), + ...$this->healthCheckItems(), + ...$this->limitItems(), + ]; + } + + /** + * @return array> + */ + private function domainItems(): array + { + return [ + $this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'), + $this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'), + $this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->application->custom_labels)), + $this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration)), + $this->item('is_force_https_enabled', 'Force HTTPS', data_get($this->application, 'settings.is_force_https_enabled'), 'redeploy'), + $this->item('is_gzip_enabled', 'Gzip', data_get($this->application, 'settings.is_gzip_enabled'), 'redeploy'), + $this->item('is_stripprefix_enabled', 'Strip prefix', data_get($this->application, 'settings.is_stripprefix_enabled'), 'redeploy'), + $this->item('is_http_basic_auth_enabled', 'HTTP basic auth', $this->application->is_http_basic_auth_enabled, 'redeploy'), + $this->item('http_basic_auth_username', 'HTTP basic auth username', $this->application->http_basic_auth_username, 'redeploy'), + $this->item('http_basic_auth_password', 'HTTP basic auth password', $this->application->http_basic_auth_password, 'redeploy', sensitive: true), + ]; + } + + /** + * @return array> + */ + private function environmentItems(): array + { + return $this->application->environment_variables() + ->get() + ->sortBy('key', SORT_NATURAL | SORT_FLAG_CASE) + ->values() + ->map(fn (EnvironmentVariable $environmentVariable): array => $this->environmentItem($environmentVariable)) + ->all(); + } + + /** + * @return array> + */ + private function healthCheckItems(): array + { + return collect([ + 'health_check_enabled' => 'Health check enabled', + 'health_check_path' => 'Health check path', + 'health_check_port' => 'Health check port', + 'health_check_host' => 'Health check host', + 'health_check_method' => 'Health check method', + 'health_check_return_code' => 'Health check return code', + 'health_check_scheme' => 'Health check scheme', + 'health_check_response_text' => 'Health check response text', + 'health_check_interval' => 'Health check interval', + 'health_check_timeout' => 'Health check timeout', + 'health_check_retries' => 'Health check retries', + 'health_check_start_period' => 'Health check start period', + 'health_check_type' => 'Health check type', + 'health_check_command' => 'Health check command', + ])->map(fn (string $label, string $key): array => $this->item($key, $label, data_get($this->application, $key), 'redeploy'))->values()->all(); + } + + /** + * @return array> + */ + private function limitItems(): array + { + return collect([ + 'limits_memory' => 'Memory limit', + 'limits_memory_swap' => 'Memory swap limit', + 'limits_memory_swappiness' => 'Memory swappiness', + 'limits_memory_reservation' => 'Memory reservation', + 'limits_cpus' => 'CPU limit', + 'limits_cpuset' => 'CPU set', + 'limits_cpu_shares' => 'CPU shares', + 'swarm_replicas' => 'Swarm replicas', + 'swarm_placement_constraints' => 'Swarm placement constraints', + ])->map(fn (string $label, string $key): array => $this->item($key, $label, data_get($this->application, $key), 'redeploy'))->values()->all(); + } + + /** + * @return array + */ + private function environmentItem(EnvironmentVariable $environmentVariable): array + { + $impact = $environmentVariable->is_buildtime ? 'build' : 'redeploy'; + $compareValue = [ + 'value_hash' => $this->sensitiveHash($environmentVariable->value), + 'is_multiline' => $environmentVariable->is_multiline, + 'is_literal' => $environmentVariable->is_literal, + 'is_buildtime' => $environmentVariable->is_buildtime, + 'is_runtime' => $environmentVariable->is_runtime, + ]; + + return $this->item( + key: (string) $environmentVariable->key, + label: (string) $environmentVariable->key, + value: $compareValue, + impact: $impact, + sensitive: true, + displayValue: $this->environmentDisplayValue($environmentVariable), + ); + } + + /** + * @return array + */ + private function item(string $key, string $label, mixed $value, string $impact, bool $sensitive = false, mixed $displayValue = null): array + { + $normalizedValue = $this->normalizeValue($value); + + return [ + 'key' => $key, + 'label' => $label, + 'impact' => $impact, + 'sensitive' => $sensitive, + 'compare_value' => $sensitive ? $this->sensitiveHash($normalizedValue) : $normalizedValue, + 'display_value' => $displayValue ?? $this->displayValue($normalizedValue), + ]; + } + + private function environmentDisplayValue(EnvironmentVariable $environmentVariable): string + { + $flags = collect([ + $environmentVariable->is_buildtime ? 'build-time' : null, + $environmentVariable->is_runtime ? 'runtime' : null, + $environmentVariable->is_multiline ? 'multiline' : null, + $environmentVariable->is_literal ? 'literal' : null, + ])->filter()->implode(', '); + + return $flags ? "Hidden ({$flags})" : 'Hidden'; + } + + private function sensitiveHash(mixed $value): string + { + return hash_hmac('sha256', json_encode($value, JSON_THROW_ON_ERROR), (string) config('app.key', 'coolify')); + } + + private function normalizeValue(mixed $value): mixed + { + if ($value === '') { + return null; + } + + if (is_bool($value) || is_numeric($value) || $value === null || is_string($value)) { + return $value; + } + + if (is_array($value)) { + return Arr::sortRecursive($value); + } + + return (string) $value; + } + + private function displayValue(mixed $value): string + { + if ($value === null) { + return 'Not set'; + } + + if (is_bool($value)) { + return $value ? 'Enabled' : 'Disabled'; + } + + if (is_array($value)) { + return $this->summarizeText(json_encode($value, JSON_THROW_ON_ERROR)); + } + + return $this->summarizeText((string) $value); + } + + private function summarizeText(?string $value): string + { + if (blank($value)) { + return 'Not set'; + } + + $value = trim((string) $value); + $lines = substr_count($value, "\n") + 1; + + if ($lines > 1) { + return str($value)->limit(80)." ({$lines} lines)"; + } + + return str($value)->limit(120)->value(); + } +} diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiff.php b/app/Services/DeploymentConfiguration/ConfigurationDiff.php new file mode 100644 index 000000000..e8a206025 --- /dev/null +++ b/app/Services/DeploymentConfiguration/ConfigurationDiff.php @@ -0,0 +1,112 @@ +> $changes + */ + public function __construct( + protected array $changes = [], + protected bool $legacyFallback = false, + ) {} + + public static function unchanged(): self + { + return new self; + } + + public static function legacy(bool $changed): self + { + if (! $changed) { + return self::unchanged(); + } + + return new self([ + [ + 'key' => 'legacy.configuration', + 'section' => 'configuration', + 'section_label' => 'Configuration', + 'label' => 'Configuration', + 'type' => 'changed', + 'impact' => 'build', + 'sensitive' => false, + 'old_display_value' => 'Previously deployed configuration', + 'new_display_value' => 'Current configuration', + ], + ], true); + } + + /** + * @param array> $changes + */ + public static function fromChanges(array $changes): self + { + return new self(array_values($changes)); + } + + public function isChanged(): bool + { + return $this->changes !== []; + } + + public function isLegacyFallback(): bool + { + return $this->legacyFallback; + } + + public function count(): int + { + return count($this->changes); + } + + public function requiresBuild(): bool + { + return collect($this->changes)->contains(fn (array $change): bool => $change['impact'] === 'build'); + } + + public function requiresRedeploy(): bool + { + return $this->isChanged(); + } + + /** + * @return array> + */ + public function changes(): array + { + return $this->changes; + } + + /** + * @return array>}> + */ + public function groupedChanges(): array + { + return collect($this->changes) + ->groupBy('section') + ->map(fn (Collection $changes): array => [ + 'label' => (string) data_get($changes->first(), 'section_label', str((string) $changes->keys()->first())->headline()), + 'changes' => $changes->values()->all(), + ]) + ->all(); + } + + /** + * @return array{changed: bool, count: int, requires_build: bool, requires_redeploy: bool, legacy_fallback: bool, changes: array>} + */ + public function toArray(): array + { + return [ + 'changed' => $this->isChanged(), + 'count' => $this->count(), + 'requires_build' => $this->requiresBuild(), + 'requires_redeploy' => $this->requiresRedeploy(), + 'legacy_fallback' => $this->isLegacyFallback(), + 'changes' => $this->changes(), + ]; + } +} diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php new file mode 100644 index 000000000..27e8d4c3f --- /dev/null +++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php @@ -0,0 +1,69 @@ + $previousSnapshot + * @param array $currentSnapshot + */ + public function diff(array $previousSnapshot, array $currentSnapshot): ConfigurationDiff + { + $previousItems = $this->flattenItems($previousSnapshot); + $currentItems = $this->flattenItems($currentSnapshot); + $keys = collect(array_keys($previousItems))->merge(array_keys($currentItems))->unique()->sort(); + $changes = []; + + foreach ($keys as $key) { + $previous = $previousItems[$key] ?? null; + $current = $currentItems[$key] ?? null; + + if (($previous['compare_value'] ?? null) === ($current['compare_value'] ?? null)) { + continue; + } + + $item = $current ?? $previous; + $sensitive = (bool) data_get($item, 'sensitive', false); + $type = $previous === null ? 'added' : ($current === null ? 'removed' : 'changed'); + $displaySummary = $sensitive && $type === 'changed' ? 'Changed' : null; + + $changes[] = [ + 'key' => $key, + 'section' => data_get($item, 'section'), + 'section_label' => data_get($item, 'section_label'), + 'label' => data_get($item, 'label'), + 'type' => $type, + 'impact' => data_get($item, 'impact', 'redeploy'), + 'sensitive' => $sensitive, + 'display_summary' => $displaySummary, + 'old_display_value' => $sensitive ? ($previous === null ? 'Not set' : 'Set') : data_get($previous, 'display_value', 'Not set'), + 'new_display_value' => $sensitive ? ($current === null ? 'Removed' : 'Set') : data_get($current, 'display_value', 'Not set'), + ]; + } + + return ConfigurationDiff::fromChanges($changes); + } + + /** + * @param array $snapshot + * @return array> + */ + private function flattenItems(array $snapshot): array + { + return collect(data_get($snapshot, 'sections', [])) + ->flatMap(function (array $section, string $sectionKey): array { + return collect(data_get($section, 'items', [])) + ->mapWithKeys(function (array $item) use ($section, $sectionKey): array { + $key = $sectionKey.'.'.$item['key']; + + return [$key => array_merge($item, [ + 'section' => $sectionKey, + 'section_label' => data_get($section, 'label', str($sectionKey)->headline()->value()), + ])]; + }) + ->all(); + }) + ->all(); + } +} diff --git a/database/migrations/2026_05_11_000000_add_configuration_snapshot_to_application_deployment_queues_table.php b/database/migrations/2026_05_11_000000_add_configuration_snapshot_to_application_deployment_queues_table.php new file mode 100644 index 000000000..6a173d058 --- /dev/null +++ b/database/migrations/2026_05_11_000000_add_configuration_snapshot_to_application_deployment_queues_table.php @@ -0,0 +1,28 @@ +string('configuration_hash')->nullable()->after('docker_registry_image_tag'); + $table->json('configuration_snapshot')->nullable()->after('configuration_hash'); + $table->json('configuration_diff')->nullable()->after('configuration_snapshot'); + }); + } + + public function down(): void + { + Schema::table('application_deployment_queues', function (Blueprint $table) { + $table->dropColumn([ + 'configuration_hash', + 'configuration_snapshot', + 'configuration_diff', + ]); + }); + } +}; diff --git a/openapi.json b/openapi.json index 25aada1e1..e83538f2b 100644 --- a/openapi.json +++ b/openapi.json @@ -12791,6 +12791,18 @@ "type": "string", "nullable": true }, + "configuration_hash": { + "type": "string", + "nullable": true + }, + "configuration_snapshot": { + "type": "object", + "nullable": true + }, + "configuration_diff": { + "type": "object", + "nullable": true + }, "force_rebuild": { "type": "boolean" }, diff --git a/openapi.yaml b/openapi.yaml index 4597b06f7..523d453ff 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8158,6 +8158,15 @@ components: docker_registry_image_tag: type: string nullable: true + configuration_hash: + type: string + nullable: true + configuration_snapshot: + type: object + nullable: true + configuration_diff: + type: object + nullable: true force_rebuild: type: boolean commit: diff --git a/resources/views/components/deployment/configuration-diff.blade.php b/resources/views/components/deployment/configuration-diff.blade.php new file mode 100644 index 000000000..ffc0cd34a --- /dev/null +++ b/resources/views/components/deployment/configuration-diff.blade.php @@ -0,0 +1,70 @@ +@props([ + 'diff' => null, + 'compact' => false, +]) + +@php + $changes = data_get($diff, 'changes', []); + $count = data_get($diff, 'count', count($changes)); + $requiresBuild = data_get($diff, 'requires_build', false); +@endphp + +@if ($count > 0) +
$compact, + 'text-sm' => ! $compact, + ])> +
+ {{ $count }} configuration {{ $count === 1 ? 'change' : 'changes' }} + $requiresBuild, + 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' => ! $requiresBuild, + ])> + {{ $requiresBuild ? 'Rebuild' : 'Redeploy' }} + +
+ + @unless ($compact) +
+ @foreach (collect($changes)->groupBy('section_label') as $sectionLabel => $sectionChanges) +
+
+ {{ $sectionLabel }} +
+
+
+
+
Field
+
Type
+
From
+
+
To
+
+
+ @foreach ($sectionChanges as $change) +
+
+ {{ data_get($change, 'label') }} +
+
+ {{ data_get($change, 'type') }} +
+
+ {{ data_get($change, 'old_display_value') }} +
+
+
+ {{ data_get($change, 'new_display_value') }} +
+
+ @endforeach +
+
+
+
+ @endforeach +
+ @endunless +
+@endif diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 6ea088123..1171c2b19 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -80,9 +80,7 @@ class="text-xl font-bold tracking-wide dark:text-white hover:opacity-80 transiti
-
{{ $slot }} -
@endauth diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index 249ded1f7..bcc78d2dc 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -61,14 +61,6 @@ class="loading loading-xs dark:text-warning loading-spinner"> @endif - @if ($build_pack === 'railpack') -
- - Beta - -
- @endif @if ($build_pack === 'dockercompose')
@endif
- @if ($build_pack === 'railpack') -
- - Beta - -
- @endif @if ($build_pack === 'dockercompose')
@endif
- @if ($build_pack === 'railpack') -
- - Beta - -
- @endif @if ($build_pack === 'dockercompose') diff --git a/tests/Feature/Api/RailpackApiTest.php b/tests/Feature/Api/RailpackApiTest.php index c9dbced4b..096774686 100644 --- a/tests/Feature/Api/RailpackApiTest.php +++ b/tests/Feature/Api/RailpackApiTest.php @@ -85,8 +85,7 @@ function makeRailpackApp(array $overrides = []): Application $app->refresh(); expect($app->build_pack)->toBe('railpack'); expect($app->dockerfile)->toBeNull(); - // NOTE: dockerfile_location is normalized to '/Dockerfile' by the model - // mutator when set to null, so we cannot assert it becomes null here. + expect($app->dockerfile_location)->toBeNull(); expect($app->dockerfile_target_build)->toBeNull(); expect((bool) $app->custom_healthcheck_found)->toBeFalse(); }); diff --git a/tests/Feature/ApplicationBuildpackCleanupTest.php b/tests/Feature/ApplicationBuildpackCleanupTest.php index 0dc0a8303..0b23684ef 100644 --- a/tests/Feature/ApplicationBuildpackCleanupTest.php +++ b/tests/Feature/ApplicationBuildpackCleanupTest.php @@ -240,6 +240,27 @@ expect($application->dockerfile)->toBeNull(); }); + test('dockerfile location defaults only for dockerfile buildpack', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $nixpacksApplication = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'nixpacks', + 'dockerfile_location' => null, + ]); + + $dockerfileApplication = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'dockerfile', + 'dockerfile_location' => null, + ]); + + expect($nixpacksApplication->refresh()->dockerfile_location)->toBeNull(); + expect($dockerfileApplication->refresh()->dockerfile_location)->toBe('/Dockerfile'); + }); + test('model does not trigger cleanup when build_pack is not changed', function () { $team = Team::factory()->create(); $project = Project::factory()->create(['team_id' => $team->id]); diff --git a/tests/Feature/ApplicationConfigurationChangedTest.php b/tests/Feature/ApplicationConfigurationChangedTest.php new file mode 100644 index 000000000..3dc68292f --- /dev/null +++ b/tests/Feature/ApplicationConfigurationChangedTest.php @@ -0,0 +1,70 @@ +create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + return Application::factory()->create(array_merge([ + 'environment_id' => $environment->id, + 'status' => 'running:healthy', + 'build_command' => 'npm run build', + ], $attributes)); +} + +function configurationChangedDeployment(Application $application): ApplicationDeploymentQueue +{ + return ApplicationDeploymentQueue::create([ + 'application_id' => (string) $application->id, + 'deployment_uuid' => (string) Str::uuid(), + 'status' => 'finished', + 'commit' => 'HEAD', + ]); +} + +it('stores deployment configuration snapshot and clears pending changes', function () { + $application = configurationChangedTestApplication(); + $deployment = configurationChangedDeployment($application); + + $application->markDeploymentConfigurationApplied($deployment); + + expect($deployment->refresh()->configuration_hash)->not->toBeNull() + ->and($deployment->configuration_snapshot)->toBeArray() + ->and($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse(); +}); + +it('stores a diff between successful deployments', function () { + $application = configurationChangedTestApplication(); + $firstDeployment = configurationChangedDeployment($application); + $application->markDeploymentConfigurationApplied($firstDeployment); + + $application->update(['build_command' => 'pnpm build']); + $secondDeployment = configurationChangedDeployment($application->refresh()); + $application->markDeploymentConfigurationApplied($secondDeployment); + + expect($secondDeployment->refresh()->configuration_diff['count'])->toBe(1) + ->and(data_get($secondDeployment->configuration_diff, 'changes.0.label'))->toBe('Build command'); +}); + +it('falls back to legacy configuration hash when no deployment snapshot exists', function () { + $application = configurationChangedTestApplication(); + $application->isConfigurationChanged(save: true); + + expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse(); + + $application->update(['build_command' => 'pnpm build']); + + expect($application->refresh()->pendingDeploymentConfigurationDiff()->isLegacyFallback())->toBeTrue() + ->and($application->pendingDeploymentConfigurationDiff()->isChanged())->toBeTrue(); +}); diff --git a/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php b/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php index ac5cda4b9..4611d61f4 100644 --- a/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php +++ b/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php @@ -67,7 +67,7 @@ ], false); }); -test('existing application shows railpack beta badge in build helper copy', function () { +test('existing application shows railpack beta label in build pack selector', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, @@ -81,6 +81,5 @@ Livewire::test(General::class, ['application' => $application]) ->assertSuccessful() - ->assertSee('Railpack') - ->assertSee('Beta'); + ->assertSee('Railpack (Beta)'); }); diff --git a/tests/Feature/Livewire/ConfigurationCheckerTest.php b/tests/Feature/Livewire/ConfigurationCheckerTest.php new file mode 100644 index 000000000..3dfa44712 --- /dev/null +++ b/tests/Feature/Livewire/ConfigurationCheckerTest.php @@ -0,0 +1,119 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function configurationCheckerApplication(Environment $environment, array $attributes = []): Application +{ + return Application::factory()->create(array_merge([ + 'environment_id' => $environment->id, + 'status' => 'running:healthy', + 'build_command' => 'npm run build', + 'fqdn' => 'https://example.com', + ], $attributes)); +} + +function markConfigurationCheckerApplicationDeployed(Application $application): void +{ + $deployment = ApplicationDeploymentQueue::create([ + 'application_id' => (string) $application->id, + 'deployment_uuid' => (string) Str::uuid(), + 'status' => 'finished', + 'commit' => 'HEAD', + ]); + + $application->markDeploymentConfigurationApplied($deployment); +} + +it('does not render the notification for preview deployment toggles', function () { + $application = configurationCheckerApplication($this->environment); + markConfigurationCheckerApplicationDeployed($application); + + $application->settings->update(['is_preview_deployments_enabled' => true]); + + Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]) + ->assertDontSee('The latest deployment is not using the current configuration') + ->assertSet('isConfigurationChanged', false); +}); + +it('renders the changed configuration labels', function () { + $application = configurationCheckerApplication($this->environment); + markConfigurationCheckerApplicationDeployed($application); + + $application->update(['build_command' => 'pnpm build']); + + Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]) + ->assertSee('The latest configuration has not been applied') + ->assertSee('Build command') + ->assertSee('A rebuild is required.'); +}); + +it('does not render environment variable secret values', function () { + $application = configurationCheckerApplication($this->environment); + EnvironmentVariable::create([ + 'key' => 'API_TOKEN', + 'value' => 'old-secret', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + markConfigurationCheckerApplicationDeployed($application->refresh()); + + $application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']); + + Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]) + ->assertSee('API_TOKEN') + ->assertSee('changed') + ->assertSee('Set') + ->assertDontSee('Hidden') + ->assertDontSee('old-secret') + ->assertDontSee('new-secret'); +}); + +it('renders added environment variables as set without exposing secret values', function () { + $application = configurationCheckerApplication($this->environment); + markConfigurationCheckerApplicationDeployed($application); + + EnvironmentVariable::create([ + 'key' => 'API_TOKEN', + 'value' => 'new-secret', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]) + ->assertSee('API_TOKEN') + ->assertSee('From') + ->assertSee('Not set') + ->assertSee('To') + ->assertSee('Set') + ->assertDontSee('Hidden') + ->assertDontSee('new-secret'); +}); diff --git a/tests/Feature/NewApplicationBuildpackDefaultsTest.php b/tests/Feature/NewApplicationBuildpackDefaultsTest.php index f1bcdcb65..24c2a8fcf 100644 --- a/tests/Feature/NewApplicationBuildpackDefaultsTest.php +++ b/tests/Feature/NewApplicationBuildpackDefaultsTest.php @@ -38,14 +38,12 @@ test('public repository flow keeps railpack available after branch lookup', function () { Livewire::test(PublicGitRepository::class, ['type' => 'public']) ->set('branchFound', true) - ->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)']) - ->assertSee('Beta'); + ->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)']); }); - test('deploy key repository flow shows railpack beta label in build pack selector', function () { + test('deploy key repository flow shows railpack beta label in build pack selector without beta badge', function () { Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key']) ->set('current_step', 'repository') - ->assertSee('Railpack (Beta)') - ->assertSee('Beta'); + ->assertSee('Railpack (Beta)'); }); }); diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php new file mode 100644 index 000000000..2106697b2 --- /dev/null +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -0,0 +1,123 @@ +create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + return Application::factory()->create(array_merge([ + 'environment_id' => $environment->id, + 'status' => 'running:healthy', + 'fqdn' => 'https://example.com', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], $attributes)); +} + +function markSnapshotTestApplicationDeployed(Application $application): ApplicationDeploymentQueue +{ + $deployment = ApplicationDeploymentQueue::create([ + 'application_id' => (string) $application->id, + 'deployment_uuid' => (string) Str::uuid(), + 'status' => 'finished', + 'commit' => 'HEAD', + ]); + + $application->markDeploymentConfigurationApplied($deployment); + + return $deployment->refresh(); +} + +it('does not report preview deployment toggles as pending production configuration changes', function () { + $application = snapshotTestApplication(); + markSnapshotTestApplicationDeployed($application); + + $application->settings->update(['is_preview_deployments_enabled' => true]); + + expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse(); +}); + +it('detects build-impacting changes', function () { + $application = snapshotTestApplication(); + markSnapshotTestApplicationDeployed($application); + + $application->update(['build_command' => 'pnpm build']); + $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); + + expect($diff->isChanged())->toBeTrue() + ->and($diff->requiresBuild())->toBeTrue() + ->and(collect($diff->changes())->pluck('label'))->toContain('Build command'); +}); + +it('detects redeploy-only domain changes', function () { + $application = snapshotTestApplication(); + markSnapshotTestApplicationDeployed($application); + + $application->update(['fqdn' => 'https://new.example.com']); + $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); + + expect($diff->isChanged())->toBeTrue() + ->and($diff->requiresBuild())->toBeFalse() + ->and(collect($diff->changes())->pluck('label'))->toContain('Domains'); +}); + +it('detects environment variable value changes without exposing secret values', function () { + $application = snapshotTestApplication(); + EnvironmentVariable::create([ + 'key' => 'API_TOKEN', + 'value' => 'old-secret', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + markSnapshotTestApplicationDeployed($application->refresh()); + + $application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']); + $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); + $change = collect($diff->changes())->firstWhere('label', 'API_TOKEN'); + + expect($change)->not->toBeNull() + ->and($change['display_summary'])->toBe('Changed') + ->and($change['old_display_value'])->toBe('Set') + ->and($change['new_display_value'])->toBe('Set') + ->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret'); +}); + +it('describes added environment variables as set without exposing secret values', function () { + $application = snapshotTestApplication(); + markSnapshotTestApplicationDeployed($application); + + EnvironmentVariable::create([ + 'key' => 'API_TOKEN', + 'value' => 'new-secret', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); + $change = collect($diff->changes())->firstWhere('label', 'API_TOKEN'); + + expect($change)->not->toBeNull() + ->and($change['display_summary'])->toBeNull() + ->and($change['old_display_value'])->toBe('Not set') + ->and($change['new_display_value'])->toBe('Set') + ->and(json_encode($diff->toArray()))->not->toContain('new-secret'); +}); From 0ecd488d6a588f8847b1678f8638d24dbdbb2da9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 13 May 2026 10:04:17 +0200 Subject: [PATCH 2/3] fix(applications): refresh pending configuration changes Dispatch configuration change events after saving application source and advanced settings, and refresh the configuration checker before showing redeploy diffs. --- app/Livewire/Project/Application/Advanced.php | 3 ++ app/Livewire/Project/Application/Source.php | 2 + .../Project/Shared/ConfigurationChecker.php | 16 ++++++-- .../shared/configuration-checker.blade.php | 3 +- .../ApplicationSourceLocalhostKeyTest.php | 28 ++++++++++++- .../Livewire/ConfigurationCheckerTest.php | 39 +++++++++++++++++++ .../AdvancedStopGracePeriodTest.php | 10 +++++ 7 files changed, 95 insertions(+), 6 deletions(-) diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index 618958140..947368a0d 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -219,6 +219,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Settings saved.'); + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -237,6 +238,7 @@ public function saveCustomName() if (is_null($this->customInternalName)) { $this->syncData(true); $this->dispatch('success', 'Custom name saved.'); + $this->dispatch('configurationChanged'); return; } @@ -256,6 +258,7 @@ public function saveCustomName() } $this->syncData(true); $this->dispatch('success', 'Custom name saved.'); + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 422dd6b28..3ef5ccf7c 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -109,6 +109,7 @@ public function setPrivateKey(int $privateKeyId) $this->application->refresh(); $this->privateKeyName = $this->application->private_key->name; $this->dispatch('success', 'Private key updated!'); + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -124,6 +125,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Application source updated!'); + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Shared/ConfigurationChecker.php b/app/Livewire/Project/Shared/ConfigurationChecker.php index 1bf2ecada..d583e74e6 100644 --- a/app/Livewire/Project/Shared/ConfigurationChecker.php +++ b/app/Livewire/Project/Shared/ConfigurationChecker.php @@ -12,6 +12,7 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use Illuminate\Contracts\View\View; use Livewire\Component; class ConfigurationChecker extends Component @@ -24,7 +25,7 @@ class ConfigurationChecker extends Component public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource; - public function getListeners() + public function getListeners(): array { $teamId = auth()->user()->currentTeam()->id; @@ -34,18 +35,25 @@ public function getListeners() ]; } - public function mount() + public function mount(): void { $this->configurationChanged(); } - public function render() + public function render(): View { return view('livewire.project.shared.configuration-checker'); } - public function configurationChanged() + public function refreshConfigurationChanges(): void { + $this->configurationChanged(); + } + + public function configurationChanged(): void + { + $this->resource->refresh(); + if ($this->resource instanceof Application) { $diff = $this->resource->pendingDeploymentConfigurationDiff(); $this->isConfigurationChanged = $diff->isChanged(); diff --git a/resources/views/livewire/project/shared/configuration-checker.blade.php b/resources/views/livewire/project/shared/configuration-checker.blade.php index 7d5ee70af..2c4440dfb 100644 --- a/resources/views/livewire/project/shared/configuration-checker.blade.php +++ b/resources/views/livewire/project/shared/configuration-checker.blade.php @@ -23,7 +23,8 @@ Please redeploy to apply the new configuration. @endif @else diff --git a/tests/Feature/ApplicationSourceLocalhostKeyTest.php b/tests/Feature/ApplicationSourceLocalhostKeyTest.php index 9b9b7b184..1a38bd26e 100644 --- a/tests/Feature/ApplicationSourceLocalhostKeyTest.php +++ b/tests/Feature/ApplicationSourceLocalhostKeyTest.php @@ -24,12 +24,23 @@ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); }); +function applicationSourceValidPrivateKey(): string +{ + return '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----'; +} + describe('Application Source with localhost key (id=0)', function () { test('renders deploy key section when private_key_id is 0', function () { $privateKey = PrivateKey::create([ 'id' => 0, 'name' => 'localhost', - 'private_key' => 'test-key-content', + 'private_key' => applicationSourceValidPrivateKey(), 'team_id' => $this->team->id, ]); @@ -56,4 +67,19 @@ ->assertDontSee('Deploy Key') ->assertSee('No source connected'); }); + + test('dispatches configuration changed when source settings are saved', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'git_repository' => 'coollabsio/coolify', + 'git_branch' => 'main', + 'git_commit_sha' => 'HEAD', + ]); + + Livewire::test(Source::class, ['application' => $application]) + ->set('gitBranch', 'next') + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('configurationChanged'); + }); }); diff --git a/tests/Feature/Livewire/ConfigurationCheckerTest.php b/tests/Feature/Livewire/ConfigurationCheckerTest.php index 3dfa44712..edf8c5044 100644 --- a/tests/Feature/Livewire/ConfigurationCheckerTest.php +++ b/tests/Feature/Livewire/ConfigurationCheckerTest.php @@ -70,6 +70,45 @@ function markConfigurationCheckerApplicationDeployed(Application $application): ->assertSee('A rebuild is required.'); }); +it('refreshes configuration changes when the event is received', function () { + $application = configurationCheckerApplication($this->environment); + markConfigurationCheckerApplicationDeployed($application); + + $component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]) + ->assertSet('isConfigurationChanged', false) + ->assertDontSee('The latest configuration has not been applied'); + + $application->update(['build_command' => 'pnpm build']); + + $component + ->dispatch('configurationChanged') + ->assertSet('isConfigurationChanged', true) + ->assertSee('The latest configuration has not been applied') + ->assertSee('Build command'); +}); + +it('refreshes stale modal configuration diff before opening changes', function () { + $application = configurationCheckerApplication($this->environment); + markConfigurationCheckerApplicationDeployed($application); + + $application->update(['build_command' => 'pnpm build']); + + $component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]) + ->assertSee('Build command') + ->assertDontSee('Start command'); + + $application->update([ + 'build_command' => 'npm run build', + 'start_command' => 'node server.js', + ]); + + $component + ->call('refreshConfigurationChanges') + ->assertSet('isConfigurationChanged', true) + ->assertSee('Start command') + ->assertDontSee('Build command'); +}); + it('does not render environment variable secret values', function () { $application = configurationCheckerApplication($this->environment); EnvironmentVariable::create([ diff --git a/tests/Feature/Livewire/Project/Application/AdvancedStopGracePeriodTest.php b/tests/Feature/Livewire/Project/Application/AdvancedStopGracePeriodTest.php index 8d8de1d47..1bc179502 100644 --- a/tests/Feature/Livewire/Project/Application/AdvancedStopGracePeriodTest.php +++ b/tests/Feature/Livewire/Project/Application/AdvancedStopGracePeriodTest.php @@ -48,6 +48,16 @@ function createApplicationForAdvancedStopGracePeriodTest(): Application expect($application->settings()->first()->stop_grace_period)->toBe(300); }); +it('dispatches configuration changed when advanced settings are saved', function () { + $application = createApplicationForAdvancedStopGracePeriodTest(); + + Livewire::test(Advanced::class, ['application' => $application]) + ->set('includeSourceCommitInBuild', true) + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('configurationChanged'); +}); + it('clears the stop grace period when submitted empty', function () { $application = createApplicationForAdvancedStopGracePeriodTest(); $application->settings->update(['stop_grace_period' => 300]); From df4d9f80692261e41252292e9d473caaf693b91d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 13 May 2026 10:28:18 +0200 Subject: [PATCH 3/3] fix(applications): use preview environment variable query Call the preview environment variable relationship as a query when building the legacy configuration hash, and cover preview deployments with a regression test. --- app/Models/Application.php | 2 +- .../ApplicationConfigurationChangedTest.php | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/Models/Application.php b/app/Models/Application.php index 0c43f6c3f..97b257752 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1255,7 +1255,7 @@ private function legacyConfigurationHash(): string if ($this->pull_request_id === 0 || $this->pull_request_id === null) { $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } else { - $newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); + $newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } return md5($newConfigHash); diff --git a/tests/Feature/ApplicationConfigurationChangedTest.php b/tests/Feature/ApplicationConfigurationChangedTest.php index 3dc68292f..f862f840d 100644 --- a/tests/Feature/ApplicationConfigurationChangedTest.php +++ b/tests/Feature/ApplicationConfigurationChangedTest.php @@ -3,6 +3,7 @@ use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\Environment; +use App\Models\EnvironmentVariable; use App\Models\Project; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -57,6 +58,32 @@ function configurationChangedDeployment(Application $application): ApplicationDe ->and(data_get($secondDeployment->configuration_diff, 'changes.0.label'))->toBe('Build command'); }); +it('checks legacy preview deployment configuration hash using preview environment variable query', function () { + $application = configurationChangedTestApplication(); + + EnvironmentVariable::create([ + 'key' => 'APP_ENV', + 'value' => 'preview', + 'is_preview' => true, + 'is_multiline' => false, + 'is_literal' => false, + 'is_buildtime' => true, + 'is_runtime' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + $application->forceFill([ + 'config_hash' => 'legacy-hash', + 'pull_request_id' => 123, + ]); + + $diff = $application->pendingDeploymentConfigurationDiff(); + + expect($diff->isLegacyFallback())->toBeTrue() + ->and($diff->isChanged())->toBeTrue(); +}); + it('falls back to legacy configuration hash when no deployment snapshot exists', function () { $application = configurationChangedTestApplication(); $application->isConfigurationChanged(save: true);