coolify/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php
Andras Bacsai f8849aba73 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.
2026-05-13 09:58:58 +02:00

338 lines
15 KiB
PHP

<?php
namespace App\Services\DeploymentConfiguration;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use Illuminate\Support\Arr;
class ApplicationConfigurationSnapshot
{
public const SCHEMA_VERSION = 1;
public function __construct(protected Application $application) {}
/**
* @return array<string, mixed>
*/
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<string, mixed> $snapshot
*/
public static function hashSnapshot(array $snapshot): string
{
return hash('sha256', json_encode(self::comparableSnapshot($snapshot), JSON_THROW_ON_ERROR));
}
/**
* @param array<string, mixed> $snapshot
* @return array<string, mixed>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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();
}
}