From b76ffeec74cc18fd32def64253ac89390c5e5cb1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:16:06 +0200 Subject: [PATCH] fix(deploy): stop false pending changes for apps without a configuration snapshot The legacy configuration hash encoded serialized environment variable rows, so hashes from older versions never matched after an upgrade, and the fallback compared the configuration with an empty one. Apps without a deployment snapshot now report no pending changes, and their next deployment rebuilds the image. Closes #11101 Co-Authored-By: Claude Opus 5.5 --- app/Jobs/ApplicationDeploymentJob.php | 2 ++ app/Models/Application.php | 30 +++------------- .../ConfigurationDiff.php | 26 ++++---------- .../ApplicationConfigurationChangedTest.php | 36 +++++++++---------- 4 files changed, 30 insertions(+), 64 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index be7d747c46..42b2fc4831 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1536,6 +1536,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->rolling_update(); return true; + } elseif ($configurationDiff->isLegacyFallback()) { + $this->application_deployment_queue->addLogEntry('The last deployment stored no configuration snapshot. Rebuilding image.'); } else { $this->application_deployment_queue->addLogEntry('Build configuration changed. Rebuilding image.'); } diff --git a/app/Models/Application.php b/app/Models/Application.php index bc59218bce..cd8d33893f 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1491,23 +1491,13 @@ class Application extends BaseModel public function pendingDeploymentConfigurationDiff(): ConfigurationDiff { - $currentSnapshot = $this->deploymentConfigurationSnapshot(); - $lastDeployment = $this->get_last_successful_deployment(); - - $previousSnapshot = $lastDeployment?->configuration_snapshot; + $previousSnapshot = $this->get_last_successful_deployment()?->configuration_snapshot; if (! $previousSnapshot) { - $oldConfigHash = data_get($this, 'config_hash'); - $hasLegacyChange = $oldConfigHash === null || $oldConfigHash !== $this->legacyConfigurationHash(); - - if (! $hasLegacyChange) { - return ConfigurationDiff::unchanged(); - } - - $previousSnapshot = []; + return ConfigurationDiff::legacy(); } - return app(ConfigurationDiffer::class)->diff(ApplicationConfigurationSnapshot::withoutPreviewVolumes($previousSnapshot, $this), $currentSnapshot); + return app(ConfigurationDiffer::class)->diff(ApplicationConfigurationSnapshot::withoutPreviewVolumes($previousSnapshot, $this), $this->deploymentConfigurationSnapshot()); } public function hasPendingDeploymentConfigurationChanges(): bool @@ -1530,7 +1520,7 @@ class Application extends BaseModel $this->refresh(); if (! $deployment) { - $this->forceFill(['config_hash' => $this->legacyConfigurationHash()])->save(); + $this->forceFill(['config_hash' => $this->deploymentConfigurationHash()])->save(); return; } @@ -1558,18 +1548,6 @@ class Application extends BaseModel $this->forceFill(['config_hash' => $hash])->save(); } - private function legacyConfigurationHash(): string - { - $newConfigHash = base64_encode($this->fqdn.json_encode($this->noindexDomains()->all()).$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) { - $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->makeVisible('value')->sort()); - } else { - $newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->makeVisible('value')->sort()); - } - - return md5($newConfigHash); - } - public function customRepository() { return convertGitUrl($this->git_repository, $this->deploymentType(), $this->source); diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiff.php b/app/Services/DeploymentConfiguration/ConfigurationDiff.php index 3f0477ba35..b648331c53 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiff.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiff.php @@ -17,25 +17,13 @@ class ConfigurationDiff return new self; } - public static function legacy(bool $changed): self + /** + * The last deployment stored no configuration snapshot (it ran before v4.1), so the + * deployed configuration is unknown. Report no changes, but build on the next deployment. + */ + public static function legacy(): 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); + return new self(legacyFallback: true); } /** @@ -63,7 +51,7 @@ class ConfigurationDiff public function requiresBuild(): bool { - return collect($this->changes)->contains(fn (array $change): bool => $change['impact'] === 'build'); + return $this->legacyFallback || collect($this->changes)->contains(fn (array $change): bool => $change['impact'] === 'build'); } public function requiresRedeploy(): bool diff --git a/tests/Feature/ApplicationConfigurationChangedTest.php b/tests/Feature/ApplicationConfigurationChangedTest.php index 4650e2b12c..9c21e48fda 100644 --- a/tests/Feature/ApplicationConfigurationChangedTest.php +++ b/tests/Feature/ApplicationConfigurationChangedTest.php @@ -135,13 +135,12 @@ it('does not flag applications whose older snapshot omitted noindex domains', fu expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse(); }); -it('checks legacy preview deployment configuration hash using preview environment variable query', function () { +it('does not report changes for applications deployed before configuration snapshots existed', function () { $application = configurationChangedTestApplication(); EnvironmentVariable::create([ - 'key' => 'APP_ENV', - 'value' => 'preview', - 'is_preview' => true, + 'key' => 'APP_SECRET', + 'value' => 'secret', 'is_multiline' => false, 'is_literal' => false, 'is_buildtime' => true, @@ -150,29 +149,28 @@ it('checks legacy preview deployment configuration hash using preview environmen 'resourceable_id' => $application->id, ]); - $application->forceFill([ - 'config_hash' => 'legacy-hash', - 'pull_request_id' => 123, - ]); + configurationChangedDeployment($application); + $application->forceFill(['config_hash' => md5('hash written by an older Coolify version')])->save(); - $diff = $application->pendingDeploymentConfigurationDiff(); + $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); - expect($diff->isChanged())->toBeTrue() - ->and($diff->count())->toBeGreaterThan(0); + expect($diff->isChanged())->toBeFalse() + ->and($diff->count())->toBe(0) + ->and($diff->isLegacyFallback())->toBeTrue() + ->and($diff->requiresBuild())->toBeTrue(); }); -it('falls back to real diff against empty snapshot when no deployment snapshot exists', function () { +it('compares with the deployment snapshot once an application without one is deployed again', function () { $application = configurationChangedTestApplication(); - $application->isConfigurationChanged(save: true); - - expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse(); + configurationChangedDeployment($application); + $application->forceFill(['config_hash' => md5('hash written by an older Coolify version')])->save(); + $application->markDeploymentConfigurationApplied(configurationChangedDeployment($application)); $application->update(['build_command' => 'pnpm build']); $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); - expect($diff->isChanged())->toBeTrue() - ->and($diff->isLegacyFallback())->toBeFalse() - ->and($diff->count())->toBeGreaterThan(0) - ->and(collect($diff->changes())->pluck('label')->toArray())->toContain('Build command'); + expect($diff->isLegacyFallback())->toBeFalse() + ->and(collect($diff->changes())->pluck('label')->all())->toBe(['Build command']) + ->and($diff->requiresBuild())->toBeTrue(); });