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.
This commit is contained in:
Andras Bacsai
2026-05-13 09:58:58 +02:00
parent f098895abf
commit f8849aba73
23 changed files with 1171 additions and 89 deletions
+89 -27
View File
@@ -4,6 +4,9 @@ namespace App\Models;
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 @@ class Application extends BaseModel
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 @@ class Application extends BaseModel
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 @@ class Application extends BaseModel
}
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 @@ class Application extends BaseModel
} 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()