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/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 ce9ce7780..d583e74e6 100644 --- a/app/Livewire/Project/Shared/ConfigurationChecker.php +++ b/app/Livewire/Project/Shared/ConfigurationChecker.php @@ -12,15 +12,20 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use Illuminate\Contracts\View\View; use Livewire\Component; 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() + public function getListeners(): array { $teamId = auth()->user()->currentTeam()->id; @@ -30,18 +35,36 @@ 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(); + $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..97b257752 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,33 +1173,92 @@ 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) { $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()); } - $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..f862f840d --- /dev/null +++ b/tests/Feature/ApplicationConfigurationChangedTest.php @@ -0,0 +1,97 @@ +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(); +}); 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/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 new file mode 100644 index 000000000..edf8c5044 --- /dev/null +++ b/tests/Feature/Livewire/ConfigurationCheckerTest.php @@ -0,0 +1,158 @@ +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'); +}); 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]); 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'); +});