feat(deployments): track application configuration diffs (#10183)

This commit is contained in:
Andras Bacsai 2026-05-13 10:49:53 +02:00 committed by GitHub
commit 4ff3e4b2be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1293 additions and 95 deletions

View file

@ -537,11 +537,6 @@ private function post_deployment()
\Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage()); \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() private function deploy_simple_dockerfile()
@ -1238,8 +1233,9 @@ private function should_skip_build()
return true; return true;
} }
if (! $this->application->isConfigurationChanged()) { $configurationDiff = $this->application->pendingDeploymentConfigurationDiff();
$this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); 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->skip_build = true;
$this->generate_compose_file(); $this->generate_compose_file();
@ -1251,7 +1247,7 @@ private function should_skip_build()
return true; return true;
} else { } else {
$this->application_deployment_queue->addLogEntry('Configuration changed. Rebuilding image.'); $this->application_deployment_queue->addLogEntry('Build configuration changed. Rebuilding image.');
} }
} else { } else {
$this->application_deployment_queue->addLogEntry("Image not found ({$this->production_image_name}). Building new image."); $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, '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)); event(new ApplicationConfigurationChanged($this->application->team()->id));
if (! $this->only_this_server) { if (! $this->only_this_server) {

View file

@ -219,6 +219,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Settings saved.'); $this->dispatch('success', 'Settings saved.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -237,6 +238,7 @@ public function saveCustomName()
if (is_null($this->customInternalName)) { if (is_null($this->customInternalName)) {
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Custom name saved.'); $this->dispatch('success', 'Custom name saved.');
$this->dispatch('configurationChanged');
return; return;
} }
@ -256,6 +258,7 @@ public function saveCustomName()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Custom name saved.'); $this->dispatch('success', 'Custom name saved.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }

View file

@ -109,6 +109,7 @@ public function setPrivateKey(int $privateKeyId)
$this->application->refresh(); $this->application->refresh();
$this->privateKeyName = $this->application->private_key->name; $this->privateKeyName = $this->application->private_key->name;
$this->dispatch('success', 'Private key updated!'); $this->dispatch('success', 'Private key updated!');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -124,6 +125,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Application source updated!'); $this->dispatch('success', 'Application source updated!');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }

View file

@ -12,15 +12,20 @@
use App\Models\StandaloneMysql; use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use Illuminate\Contracts\View\View;
use Livewire\Component; use Livewire\Component;
class ConfigurationChecker extends Component class ConfigurationChecker extends Component
{ {
public bool $isConfigurationChanged = false; public bool $isConfigurationChanged = false;
public array $configurationDiff = [];
public array $groupedConfigurationChanges = [];
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource; public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
public function getListeners() public function getListeners(): array
{ {
$teamId = auth()->user()->currentTeam()->id; $teamId = auth()->user()->currentTeam()->id;
@ -30,18 +35,36 @@ public function getListeners()
]; ];
} }
public function mount() public function mount(): void
{ {
$this->configurationChanged(); $this->configurationChanged();
} }
public function render() public function render(): View
{ {
return view('livewire.project.shared.configuration-checker'); 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();
$this->configurationDiff = $diff->toArray();
$this->groupedConfigurationChanges = $diff->groupedChanges();
return;
}
$this->isConfigurationChanged = $this->resource->isConfigurationChanged(); $this->isConfigurationChanged = $this->resource->isConfigurationChanged();
$this->configurationDiff = [];
$this->groupedConfigurationChanges = [];
} }
} }

View file

@ -4,6 +4,9 @@
use App\Enums\ApplicationDeploymentStatus; use App\Enums\ApplicationDeploymentStatus;
use App\Services\ConfigurationGenerator; 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\ClearsGlobalSearchCache;
use App\Traits\HasConfiguration; use App\Traits\HasConfiguration;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
@ -720,14 +723,14 @@ public function dockerfileLocation(): Attribute
return Attribute::make( return Attribute::make(
set: function ($value) { set: function ($value) {
if (is_null($value) || $value === '') { if (is_null($value) || $value === '') {
return '/Dockerfile'; return $this->build_pack === 'dockerfile' ? '/Dockerfile' : null;
} else {
if ($value !== '/') {
return Str::start(Str::replaceEnd('/', '', $value), '/');
}
return Str::start($value, '/');
} }
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() 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() public function get_last_days_deployments()
@ -1170,33 +1173,92 @@ public function isLogDrainEnabled()
} }
public function isConfigurationChanged(bool $save = false) 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); $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) { 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()); $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
} else { } 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());
} }
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
if ($oldConfigHash === null) {
if ($save) {
$this->config_hash = $newConfigHash;
$this->save();
}
return true; return md5($newConfigHash);
}
if ($oldConfigHash === $newConfigHash) {
return false;
} else {
if ($save) {
$this->config_hash = $newConfigHash;
$this->save();
}
return true;
}
} }
public function customRepository() public function customRepository()

View file

@ -17,6 +17,9 @@
'deployment_uuid' => ['type' => 'string'], 'deployment_uuid' => ['type' => 'string'],
'pull_request_id' => ['type' => 'integer'], 'pull_request_id' => ['type' => 'integer'],
'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true], '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'], 'force_rebuild' => ['type' => 'boolean'],
'commit' => ['type' => 'string'], 'commit' => ['type' => 'string'],
'status' => ['type' => 'string'], 'status' => ['type' => 'string'],
@ -45,6 +48,9 @@ class ApplicationDeploymentQueue extends Model
'deployment_uuid', 'deployment_uuid',
'pull_request_id', 'pull_request_id',
'docker_registry_image_tag', 'docker_registry_image_tag',
'configuration_hash',
'configuration_snapshot',
'configuration_diff',
'force_rebuild', 'force_rebuild',
'commit', 'commit',
'status', 'status',
@ -71,6 +77,8 @@ class ApplicationDeploymentQueue extends Model
protected $casts = [ protected $casts = [
'pull_request_id' => 'integer', 'pull_request_id' => 'integer',
'finished_at' => 'datetime', 'finished_at' => 'datetime',
'configuration_snapshot' => 'array',
'configuration_diff' => 'array',
]; ];
public function application() public function application()

View file

@ -0,0 +1,338 @@
<?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();
}
}

View file

@ -0,0 +1,112 @@
<?php
namespace App\Services\DeploymentConfiguration;
use Illuminate\Support\Collection;
class ConfigurationDiff
{
/**
* @param array<int, array<string, mixed>> $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<int, array<string, mixed>> $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<int, array<string, mixed>>
*/
public function changes(): array
{
return $this->changes;
}
/**
* @return array<string, array{label: string, changes: array<int, array<string, mixed>>}>
*/
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<int, array<string, mixed>>}
*/
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(),
];
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Services\DeploymentConfiguration;
class ConfigurationDiffer
{
/**
* @param array<string, mixed> $previousSnapshot
* @param array<string, mixed> $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<string, mixed> $snapshot
* @return array<string, array<string, mixed>>
*/
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();
}
}

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('application_deployment_queues', function (Blueprint $table) {
$table->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',
]);
});
}
};

View file

@ -12791,6 +12791,18 @@
"type": "string", "type": "string",
"nullable": true "nullable": true
}, },
"configuration_hash": {
"type": "string",
"nullable": true
},
"configuration_snapshot": {
"type": "object",
"nullable": true
},
"configuration_diff": {
"type": "object",
"nullable": true
},
"force_rebuild": { "force_rebuild": {
"type": "boolean" "type": "boolean"
}, },

View file

@ -8158,6 +8158,15 @@ components:
docker_registry_image_tag: docker_registry_image_tag:
type: string type: string
nullable: true nullable: true
configuration_hash:
type: string
nullable: true
configuration_snapshot:
type: object
nullable: true
configuration_diff:
type: object
nullable: true
force_rebuild: force_rebuild:
type: boolean type: boolean
commit: commit:

View file

@ -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)
<div @class([
'text-xs' => $compact,
'text-sm' => ! $compact,
])>
<div class="mb-2 flex flex-wrap items-center gap-2 font-semibold text-black dark:text-white">
<span>{{ $count }} configuration {{ $count === 1 ? 'change' : 'changes' }}</span>
<span @class([
'rounded-sm px-1.5 py-0.5 text-[0.65rem] font-semibold uppercase leading-none',
'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300' => $requiresBuild,
'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' => ! $requiresBuild,
])>
{{ $requiresBuild ? 'Rebuild' : 'Redeploy' }}
</span>
</div>
@unless ($compact)
<div class="space-y-2">
@foreach (collect($changes)->groupBy('section_label') as $sectionLabel => $sectionChanges)
<div>
<div class="mb-0.5 text-[0.65rem] font-semibold uppercase tracking-wide text-neutral-600 dark:text-neutral-400">
{{ $sectionLabel }}
</div>
<div class="overflow-x-auto rounded-sm border border-neutral-300 dark:border-coolgray-200">
<div class="min-w-[44rem]">
<div class="grid grid-cols-[minmax(12rem,1.4fr)_7rem_minmax(8rem,1fr)_1.5rem_minmax(8rem,1fr)] items-center gap-2 bg-neutral-100 px-3 py-1.5 text-[0.65rem] font-semibold uppercase tracking-wide text-neutral-500 dark:bg-coolgray-200 dark:text-neutral-400">
<div>Field</div>
<div>Type</div>
<div>From</div>
<div></div>
<div>To</div>
</div>
<div class="divide-y divide-neutral-300 dark:divide-coolgray-200">
@foreach ($sectionChanges as $change)
<div class="grid grid-cols-[minmax(12rem,1.4fr)_7rem_minmax(8rem,1fr)_1.5rem_minmax(8rem,1fr)] items-center gap-2 px-3 py-1.5 text-neutral-700 dark:text-neutral-300">
<div class="truncate font-medium text-black dark:text-white" title="{{ data_get($change, 'label') }}">
{{ data_get($change, 'label') }}
</div>
<div class="text-neutral-500 dark:text-neutral-400">
{{ data_get($change, 'type') }}
</div>
<div class="truncate" title="{{ data_get($change, 'old_display_value') }}">
{{ data_get($change, 'old_display_value') }}
</div>
<div class="text-center text-neutral-500 dark:text-neutral-400"></div>
<div class="truncate" title="{{ data_get($change, 'new_display_value') }}">
{{ data_get($change, 'new_display_value') }}
</div>
</div>
@endforeach
</div>
</div>
</div>
</div>
@endforeach
</div>
@endunless
</div>
@endif

View file

@ -80,9 +80,7 @@ class="text-xl font-bold tracking-wide dark:text-white hover:opacity-80 transiti
</div> </div>
<main class="transition-[padding] duration-200 p-6" :class="collapsed ? 'lg:pl-[6rem]' : 'lg:pl-[16rem]'"> <main class="transition-[padding] duration-200 p-6" :class="collapsed ? 'lg:pl-[6rem]' : 'lg:pl-[16rem]'">
<div>
{{ $slot }} {{ $slot }}
</div>
</main> </main>
</div> </div>
@endauth @endauth

View file

@ -61,14 +61,6 @@ class="loading loading-xs dark:text-warning loading-spinner"></span>
<x-forms.input id="publish_directory" required label="Publish Directory" /> <x-forms.input id="publish_directory" required label="Publish Directory" />
@endif @endif
</div> </div>
@if ($build_pack === 'railpack')
<div>
<span
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
Beta
</span>
</div>
@endif
@if ($build_pack === 'dockercompose') @if ($build_pack === 'dockercompose')
<div x-data="{ <div x-data="{
baseDir: '{{ $base_directory }}', baseDir: '{{ $base_directory }}',

View file

@ -93,14 +93,6 @@
helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." /> helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." />
@endif @endif
</div> </div>
@if ($build_pack === 'railpack')
<div>
<span
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
Beta
</span>
</div>
@endif
@if ($build_pack === 'dockercompose') @if ($build_pack === 'dockercompose')
<div x-data="{ <div x-data="{
baseDir: '{{ $base_directory }}', baseDir: '{{ $base_directory }}',

View file

@ -52,14 +52,6 @@
helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." /> helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." />
@endif @endif
</div> </div>
@if ($build_pack === 'railpack')
<div>
<span
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
Beta
</span>
</div>
@endif
@if ($build_pack === 'dockercompose') @if ($build_pack === 'dockercompose')
<div x-data="{ <div x-data="{
baseDir: '{{ $base_directory }}', baseDir: '{{ $base_directory }}',

View file

@ -1,22 +1,77 @@
<div> <div>
@if ($isConfigurationChanged && !is_null($resource->config_hash) && !$resource->isExited()) @if ($isConfigurationChanged && !is_null($resource->config_hash) && !$resource->isExited())
<x-popup-small> <div x-data="{ configurationDiffModalOpen: false }">
<x-slot:title> <x-popup-small>
The latest configuration has not been applied <x-slot:title>
</x-slot:title> The latest configuration has not been applied
<x-slot:icon> </x-slot:title>
<svg class="hidden w-10 h-10 dark:text-warning lg:block" viewBox="0 0 256 256" <x-slot:icon>
xmlns="http://www.w3.org/2000/svg"> <svg class="hidden w-10 h-10 dark:text-warning lg:block" viewBox="0 0 256 256"
<path fill="currentColor" xmlns="http://www.w3.org/2000/svg">
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16" /> <path fill="currentColor"
</svg> d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16" />
</x-slot:icon> </svg>
<x-slot:description> </x-slot:icon>
<span>Please redeploy to apply the new configuration.</span> <x-slot:description>
</x-slot:description> <span>
<x-slot:button-text @click="disableSponsorship()"> @if (data_get($configurationDiff, 'count'))
Disable This Popup {{ data_get($configurationDiff, 'count') }} unapplied configuration
</x-slot:button-text> {{ data_get($configurationDiff, 'count') === 1 ? 'change' : 'changes' }} detected.
</x-popup-small> @if (data_get($configurationDiff, 'requires_build'))
A rebuild is required.
@else
Please redeploy to apply the new configuration.
@endif
<button type="button" class="ml-1 font-semibold underline text-coollabs dark:text-warning"
x-on:click="$wire.refreshConfigurationChanges().then(() => configurationDiffModalOpen = true)"
wire:loading.attr="disabled" wire:target="refreshConfigurationChanges">
View changes
</button>
@else
Please redeploy to apply the new configuration.
@endif
</span>
</x-slot:description>
</x-popup-small>
@if (data_get($configurationDiff, 'count'))
<template x-teleport="body">
<div x-show="configurationDiffModalOpen" x-cloak
class="fixed inset-0 z-99 flex h-screen w-screen items-center justify-center p-4"
@keydown.escape.window="configurationDiffModalOpen = false">
<div x-show="configurationDiffModalOpen" x-transition.opacity
class="absolute inset-0 h-full w-full bg-black/20 backdrop-blur-xs"
@click="configurationDiffModalOpen = false"></div>
<div x-show="configurationDiffModalOpen" x-trap.inert.noscroll="configurationDiffModalOpen"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative flex max-h-[85vh] w-full flex-col rounded-sm border border-neutral-200 bg-white shadow-lg dark:border-coolgray-300 dark:bg-base lg:max-w-4xl">
<div class="flex shrink-0 items-center justify-between border-b border-neutral-200 px-6 py-5 dark:border-coolgray-300">
<div>
<h3 class="text-2xl font-bold text-black dark:text-white">Configuration changes</h3>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
These changes are not applied to the latest deployment yet.
</p>
</div>
<button type="button" @click="configurationDiffModalOpen = false"
class="flex h-8 w-8 items-center justify-center rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base">
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="overflow-y-auto p-6">
<x-deployment.configuration-diff :diff="$configurationDiff" />
</div>
</div>
</div>
</template>
@endif
</div>
@endif @endif
</div> </div>

View file

@ -85,8 +85,7 @@ function makeRailpackApp(array $overrides = []): Application
$app->refresh(); $app->refresh();
expect($app->build_pack)->toBe('railpack'); expect($app->build_pack)->toBe('railpack');
expect($app->dockerfile)->toBeNull(); expect($app->dockerfile)->toBeNull();
// NOTE: dockerfile_location is normalized to '/Dockerfile' by the model expect($app->dockerfile_location)->toBeNull();
// mutator when set to null, so we cannot assert it becomes null here.
expect($app->dockerfile_target_build)->toBeNull(); expect($app->dockerfile_target_build)->toBeNull();
expect((bool) $app->custom_healthcheck_found)->toBeFalse(); expect((bool) $app->custom_healthcheck_found)->toBeFalse();
}); });

View file

@ -240,6 +240,27 @@
expect($application->dockerfile)->toBeNull(); 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 () { test('model does not trigger cleanup when build_pack is not changed', function () {
$team = Team::factory()->create(); $team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]); $project = Project::factory()->create(['team_id' => $team->id]);

View file

@ -0,0 +1,97 @@
<?php
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;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function configurationChangedTestApplication(array $attributes = []): Application
{
$team = Team::factory()->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('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);
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
$application->update(['build_command' => 'pnpm build']);
expect($application->refresh()->pendingDeploymentConfigurationDiff()->isLegacyFallback())->toBeTrue()
->and($application->pendingDeploymentConfigurationDiff()->isChanged())->toBeTrue();
});

View file

@ -67,7 +67,7 @@
], false); ], 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([ $application = Application::factory()->create([
'environment_id' => $this->environment->id, 'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id, 'destination_id' => $this->destination->id,
@ -81,6 +81,5 @@
Livewire::test(General::class, ['application' => $application]) Livewire::test(General::class, ['application' => $application])
->assertSuccessful() ->assertSuccessful()
->assertSee('Railpack') ->assertSee('Railpack (Beta)');
->assertSee('Beta');
}); });

View file

@ -24,12 +24,23 @@
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]); $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 () { describe('Application Source with localhost key (id=0)', function () {
test('renders deploy key section when private_key_id is 0', function () { test('renders deploy key section when private_key_id is 0', function () {
$privateKey = PrivateKey::create([ $privateKey = PrivateKey::create([
'id' => 0, 'id' => 0,
'name' => 'localhost', 'name' => 'localhost',
'private_key' => 'test-key-content', 'private_key' => applicationSourceValidPrivateKey(),
'team_id' => $this->team->id, 'team_id' => $this->team->id,
]); ]);
@ -56,4 +67,19 @@
->assertDontSee('Deploy Key') ->assertDontSee('Deploy Key')
->assertSee('No source connected'); ->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');
});
}); });

View file

@ -0,0 +1,158 @@
<?php
use App\Livewire\Project\Shared\ConfigurationChecker;
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 App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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('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([
'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');
});

View file

@ -48,6 +48,16 @@ function createApplicationForAdvancedStopGracePeriodTest(): Application
expect($application->settings()->first()->stop_grace_period)->toBe(300); 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 () { it('clears the stop grace period when submitted empty', function () {
$application = createApplicationForAdvancedStopGracePeriodTest(); $application = createApplicationForAdvancedStopGracePeriodTest();
$application->settings->update(['stop_grace_period' => 300]); $application->settings->update(['stop_grace_period' => 300]);

View file

@ -38,14 +38,12 @@
test('public repository flow keeps railpack available after branch lookup', function () { test('public repository flow keeps railpack available after branch lookup', function () {
Livewire::test(PublicGitRepository::class, ['type' => 'public']) Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->set('branchFound', true) ->set('branchFound', true)
->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)']) ->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)']);
->assertSee('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']) Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key'])
->set('current_step', 'repository') ->set('current_step', 'repository')
->assertSee('Railpack (Beta)') ->assertSee('Railpack (Beta)');
->assertSee('Beta');
}); });
}); });

View file

@ -0,0 +1,123 @@
<?php
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;
use Illuminate\Support\Str;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function snapshotTestApplication(array $attributes = []): Application
{
$team = Team::factory()->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');
});