From 45f931ecc8bc0979bc67925744f8b35076c5fc72 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 27 Dec 2025 15:25:21 +0100 Subject: [PATCH] Add encrypted env var backup in metadata.json for rebuild rollbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Store encrypted build and runtime environment variables in metadata.json - Runtime vars duplicated in .env (plain text for docker compose) and metadata.json (encrypted backup for rebuild if .env deleted) - Build vars only in metadata.json (needed for exact image reproduction) - Add rollback_deployment_uuid column to track which deployment is being rolled back to during rebuild rollbacks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../Application/SaveDeploymentSnapshot.php | 63 ++++- app/Jobs/ApplicationDeploymentJob.php | 259 ++++++++++++++++-- app/Livewire/Project/Application/Rollback.php | 2 + app/Models/ApplicationDeploymentQueue.php | 1 + bootstrap/helpers/applications.php | 3 +- ..._uuid_to_application_deployment_queues.php | 22 ++ openapi.json | 4 + openapi.yaml | 3 + .../project/application/rollback.blade.php | 99 ++++--- 9 files changed, 391 insertions(+), 65 deletions(-) create mode 100644 database/migrations/2025_12_19_000001_add_rollback_deployment_uuid_to_application_deployment_queues.php diff --git a/app/Actions/Application/SaveDeploymentSnapshot.php b/app/Actions/Application/SaveDeploymentSnapshot.php index 1687bd5e74..6d48b46dc5 100644 --- a/app/Actions/Application/SaveDeploymentSnapshot.php +++ b/app/Actions/Application/SaveDeploymentSnapshot.php @@ -18,7 +18,8 @@ class SaveDeploymentSnapshot string $dockerComposeContent, string $envContent, ?string $dockerfileContent = null, - ?string $productionImageName = null + ?string $productionImageName = null, + ?string $configHash = null ): bool { $deploymentDir = deployment_configuration_dir($application->uuid, $deployment->deployment_uuid); $deploymentsBaseDir = deployments_base_dir($application->uuid); @@ -51,7 +52,7 @@ class SaveDeploymentSnapshot } // Generate and save metadata - $metadata = $this->generateMetadata($application, $deployment, $productionImageName); + $metadata = $this->generateMetadata($application, $deployment, $productionImageName, $configHash); $metadataJson = json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); $metadataBase64 = base64_encode($metadataJson); instant_remote_process([ @@ -73,7 +74,7 @@ class SaveDeploymentSnapshot return true; } - private function generateMetadata(Application $application, ApplicationDeploymentQueue $deployment, ?string $productionImageName = null): array + private function generateMetadata(Application $application, ApplicationDeploymentQueue $deployment, ?string $productionImageName = null, ?string $configHash = null): array { // Use the actual production image name if provided (includes config hash for rollback support) // Fall back to commit-based name for backward compatibility with older deployments @@ -90,6 +91,7 @@ class SaveDeploymentSnapshot 'branch' => $application->git_branch, 'build_pack' => $application->build_pack, 'image_name' => $imageName, + 'config_hash' => $configHash, 'pull_request_id' => $deployment->pull_request_id, 'git_repository' => $application->git_repository, 'configuration_snapshot' => [ @@ -106,7 +108,62 @@ class SaveDeploymentSnapshot 'health_check_port' => $application->health_check_port, 'limits_memory' => $application->limits_memory, 'limits_cpus' => $application->limits_cpus, + // Additional fields for rebuild rollback support + 'static_image' => $application->static_image, + 'dockerfile' => $application->dockerfile, + 'dockerfile_target_build' => $application->dockerfile_target_build, + 'custom_docker_run_options' => $application->custom_docker_run_options, + 'custom_labels' => $application->custom_labels, + 'docker_compose_custom_build_command' => $application->docker_compose_custom_build_command, + 'docker_compose_custom_start_command' => $application->docker_compose_custom_start_command, + 'docker_compose_location' => $application->docker_compose_location, ], + 'settings_snapshot' => $this->serializeSettings($application), + 'build_environment_variables' => $this->serializeEnvironmentVariables($application, true), + 'runtime_environment_variables' => $this->serializeEnvironmentVariables($application, false), ]; } + + private function serializeSettings(Application $application): array + { + $settings = $application->settings; + if (! $settings) { + return []; + } + + return [ + 'use_build_secrets' => $settings->use_build_secrets ?? false, + 'inject_build_args_to_dockerfile' => $settings->inject_build_args_to_dockerfile ?? false, + 'include_source_commit_in_build' => $settings->include_source_commit_in_build ?? false, + 'disable_build_cache' => $settings->disable_build_cache ?? false, + ]; + } + + private function serializeEnvironmentVariables(Application $application, bool $buildTime): array + { + // Query based on the appropriate field: + // - For build-time vars: use is_buildtime = true + // - For runtime vars: use is_runtime = true + // This is important because a variable can have both flags set (used in both contexts) + if ($buildTime) { + $envVars = $application->environment_variables() + ->where('is_buildtime', true) + ->get(); + } else { + $envVars = $application->environment_variables() + ->where('is_runtime', true) + ->get(); + } + + return $envVars->map(function ($var) { + return [ + 'key' => $var->key, + 'value' => encrypt($var->real_value), // Encrypt value for secure storage on server + 'is_multiline' => $var->is_multiline ?? false, + 'is_literal' => $var->is_literal ?? false, + 'is_buildtime' => $var->is_buildtime ?? false, + 'is_runtime' => $var->is_runtime ?? true, + ]; + })->toArray(); + } } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 18072a5dc6..52ebc19eb7 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -69,6 +69,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private bool $rollback; + private ?string $rollback_deployment_uuid = null; + + private ?array $rollback_snapshot = null; + private bool $force_rebuild; private bool $restart_only; @@ -125,6 +129,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue */ private ?string $cachedConfigHash = null; + /** + * Cached deployment timestamp computed once at the start of deployment. + * Used for image tagging in pure Dockerfile deployments where no git commit exists. + * Format: YmdHis (e.g., 20251219143025) + */ + private ?string $cachedDeploymentTimestamp = null; + private bool $is_debug_enabled; private Collection|string $build_args; @@ -209,6 +220,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->pull_request_id = $this->application_deployment_queue->pull_request_id; $this->commit = $this->application_deployment_queue->commit; $this->rollback = $this->application_deployment_queue->rollback; + $this->rollback_deployment_uuid = $this->application_deployment_queue->rollback_deployment_uuid; $this->disableBuildCache = $this->application->settings->disable_build_cache; $this->force_rebuild = $this->application_deployment_queue->force_rebuild; if ($this->disableBuildCache) { @@ -292,6 +304,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue try { // Make sure the private key is stored in the filesystem $this->server->privateKey->storeInFileSystem(); + + // Load and apply rollback snapshot if this is a rollback rebuild + if ($this->rollback && $this->rollback_deployment_uuid) { + $this->rollback_snapshot = $this->loadRollbackSnapshot(); + if ($this->rollback_snapshot) { + $this->applyRollbackConfiguration(); + } + } + // Generate custom host<->ip mapping $allContainers = instant_remote_process(["docker network inspect {$this->destination->network} -f '{{json .Containers}}' "], $this->server); @@ -499,6 +520,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue // This ensures the deployment status is FINISHED even if subsequent operations fail $this->completeDeployment(); + // Save versioned deployment snapshot for rollback functionality + // This must be called AFTER completeDeployment() sets status to FINISHED + $this->save_deployment_snapshot(); + // Then handle side effects - these should not fail the deployment try { GetContainersStatus::dispatch($this->server); @@ -1004,8 +1029,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } - // Save versioned deployment snapshot for rollback functionality - $this->save_deployment_snapshot(); } private function save_deployment_snapshot() @@ -1016,11 +1039,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return; } - // Skip if deployment failed or is not finished - if ($this->application_deployment_queue->status !== ApplicationDeploymentStatus::FINISHED->value) { - return; - } - $serverForSnapshot = $this->use_build_server ? $this->original_server : $this->server; // Get docker-compose content @@ -1058,7 +1076,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $dockerComposeContent, $envContent, $dockerfileContent, - $this->production_image_name + $this->production_image_name, + $this->cachedConfigHash ); // Cleanup old deployments to maintain retention policy @@ -1161,12 +1180,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function generate_image_names() { if ($this->application->dockerfile) { + // Pure Dockerfile apps have no git commit, use timestamp for unique tags + // Include config hash for consistency (allows distinguishing same-time builds with different build args) + $timestamp = $this->getDeploymentTimestamp(); + $configHash = $this->computeCurrentConfigHash(); + $imageTag = "{$timestamp}-{$configHash}"; + if ($this->application->docker_registry_image_name) { - $this->build_image_name = "{$this->application->docker_registry_image_name}:build"; - $this->production_image_name = "{$this->application->docker_registry_image_name}:latest"; + $this->build_image_name = "{$this->application->docker_registry_image_name}:{$imageTag}-build"; + $this->production_image_name = "{$this->application->docker_registry_image_name}:{$imageTag}"; } else { - $this->build_image_name = "{$this->application->uuid}:build"; - $this->production_image_name = "{$this->application->uuid}:latest"; + $this->build_image_name = "{$this->application->uuid}:{$imageTag}-build"; + $this->production_image_name = "{$this->application->uuid}:{$imageTag}"; } } elseif ($this->application->build_pack === 'dockerimage') { // Check if this is an image hash deployment @@ -1230,6 +1255,180 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $this->cachedConfigHash; } + /** + * Get deployment timestamp for image tagging. + * Returns timestamp in YmdHis format (e.g., 20251219143025). + * + * This method caches the timestamp on first computation to ensure + * the SAME timestamp is used throughout deployment for: + * - Docker image tag + * - docker-compose.yaml image reference + * - metadata.json image_name field + * + * This guarantees rollback can find the correct image for each deployment. + */ + private function getDeploymentTimestamp(): string + { + if ($this->cachedDeploymentTimestamp !== null) { + return $this->cachedDeploymentTimestamp; + } + + $this->cachedDeploymentTimestamp = now()->format('YmdHis'); + + return $this->cachedDeploymentTimestamp; + } + + /** + * Load the deployment snapshot from the server for rollback rebuild. + * This reads the metadata.json file from the original deployment directory. + */ + private function loadRollbackSnapshot(): ?array + { + if (! $this->rollback || ! $this->rollback_deployment_uuid) { + return null; + } + + $deploymentDir = deployment_configuration_dir( + $this->application->uuid, + $this->rollback_deployment_uuid + ); + + $this->application_deployment_queue->addLogEntry("Loading configuration snapshot from deployment: {$this->rollback_deployment_uuid}"); + + $metadataJson = instant_remote_process([ + "cat {$deploymentDir}/metadata.json 2>/dev/null || echo '{}'", + ], $this->server, throwError: false); + + $metadata = json_decode($metadataJson, true); + + if (empty($metadata) || ! isset($metadata['version'])) { + $this->application_deployment_queue->addLogEntry( + 'Warning: Rollback snapshot not found or invalid. Using current configuration.', + 'stderr' + ); + + return null; + } + + // Check version for compatibility + if (version_compare($metadata['version'], '2.0', '<')) { + $this->application_deployment_queue->addLogEntry( + 'Warning: Rollback snapshot is from an older version (v'.$metadata['version'].'). Some configuration may not be restored.', + 'stderr' + ); + } + + $this->application_deployment_queue->addLogEntry('Snapshot loaded successfully (version '.$metadata['version'].').'); + + return $metadata; + } + + /** + * Apply the rollback snapshot configuration to the application. + * This overrides application properties in memory (NOT saved to database). + */ + private function applyRollbackConfiguration(): void + { + if (! $this->rollback_snapshot) { + return; + } + + $this->application_deployment_queue->addLogEntry('Applying configuration from rollback snapshot...'); + + $config = $this->rollback_snapshot['configuration_snapshot'] ?? []; + + // Override application properties for this deployment + // These changes are NOT saved to database - only used for this build + $fieldsToApply = [ + 'install_command', 'build_command', 'start_command', + 'dockerfile_location', 'base_directory', 'publish_directory', + 'static_image', 'dockerfile', 'dockerfile_target_build', + 'custom_docker_run_options', 'custom_labels', + 'docker_compose_custom_build_command', 'docker_compose_custom_start_command', + 'docker_compose_location', 'ports_exposes', 'ports_mappings', + 'health_check_path', 'health_check_port', 'limits_memory', 'limits_cpus', + ]; + + foreach ($fieldsToApply as $field) { + if (array_key_exists($field, $config) && $config[$field] !== null) { + $this->application->setAttribute($field, $config[$field]); + } + } + + // Apply settings snapshot + $settingsSnapshot = $this->rollback_snapshot['settings_snapshot'] ?? []; + if ($this->application->settings && ! empty($settingsSnapshot)) { + foreach ($settingsSnapshot as $key => $value) { + if ($value !== null) { + $this->application->settings->setAttribute($key, $value); + } + } + } + + // Store config hash from snapshot for image tagging + if (isset($this->rollback_snapshot['config_hash']) && $this->rollback_snapshot['config_hash']) { + $this->cachedConfigHash = $this->rollback_snapshot['config_hash']; + $this->application_deployment_queue->addLogEntry('Using config hash from snapshot: '.$this->cachedConfigHash); + } + + // Extract timestamp from snapshot image_name for pure Dockerfile rollbacks + // Image format: {uuid}:{timestamp}-{configHash} (e.g., app:20251219143025-7f9e2c1a) + if (isset($this->rollback_snapshot['image_name'])) { + if (preg_match('/:(\d{14})-[a-f0-9]+$/', $this->rollback_snapshot['image_name'], $matches)) { + $this->cachedDeploymentTimestamp = $matches[1]; + $this->application_deployment_queue->addLogEntry('Using timestamp from snapshot: '.$this->cachedDeploymentTimestamp); + } + } + + $this->application_deployment_queue->addLogEntry('Rollback configuration applied.'); + } + + /** + * Get build environment variables from the rollback snapshot. + * Returns null if no snapshot or no build vars in snapshot. + */ + private function getRollbackBuildEnvironmentVariables(): ?Collection + { + if (! $this->rollback_snapshot || ! isset($this->rollback_snapshot['build_environment_variables'])) { + return null; + } + + $vars = $this->rollback_snapshot['build_environment_variables']; + if (empty($vars)) { + return null; + } + + $this->application_deployment_queue->addLogEntry('Using '.count($vars).' build environment variables from rollback snapshot.'); + + return collect($vars)->map(function ($var) { + $var['value'] = decrypt($var['value']); // Decrypt value from encrypted metadata + + return (object) $var; + }); + } + + /** + * Get runtime environment variables from the rollback snapshot. + * Returns null if no snapshot or no runtime vars in snapshot. + */ + private function getRollbackRuntimeEnvironmentVariables(): ?Collection + { + if (! $this->rollback_snapshot || ! isset($this->rollback_snapshot['runtime_environment_variables'])) { + return null; + } + + $vars = $this->rollback_snapshot['runtime_environment_variables']; + if (empty($vars)) { + return null; + } + + return collect($vars)->map(function ($var) { + $var['value'] = decrypt($var['value']); // Decrypt value from encrypted metadata + + return (object) $var; + }); + } + private function just_restart() { $this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}."); @@ -1308,7 +1507,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { $envs = collect([]); $sort = $this->application->settings->is_env_sorting_enabled; - if ($sort) { + + // For rollback rebuilds, use runtime environment variables from the snapshot if available + $rollbackRuntimeVars = $this->getRollbackRuntimeEnvironmentVariables(); + + if ($rollbackRuntimeVars !== null) { + $this->application_deployment_queue->addLogEntry('Using '.count($rollbackRuntimeVars).' runtime environment variables from rollback snapshot.'); + $sorted_environment_variables = $sort ? $rollbackRuntimeVars->sortBy('key') : $rollbackRuntimeVars; + $sorted_environment_variables_preview = collect([]); // No preview for rollback + } elseif ($sort) { $sorted_environment_variables = $this->application->environment_variables->sortBy('key'); $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('key'); } else { @@ -1374,7 +1581,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue }); foreach ($runtime_environment_variables as $env) { - $envs->push($env->key.'='.$env->real_value); + // For snapshot variables, use 'value' directly (already decrypted) + // For DB variables, use 'real_value' which handles decryption + $envValue = property_exists($env, 'real_value') ? $env->real_value : ($env->value ?? ''); + $envs->push($env->key.'='.$envValue); } // Check for PORT environment variable mismatch with ports_exposes @@ -1440,7 +1650,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue }); foreach ($runtime_environment_variables_preview as $env) { - $envs->push($env->key.'='.$env->real_value); + // For snapshot variables, use 'value' directly (already decrypted) + // For DB variables, use 'real_value' which handles decryption + $envValue = property_exists($env, 'real_value') ? $env->real_value : ($env->value ?? ''); + $envs->push($env->key.'='.$envValue); } // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { @@ -1676,8 +1889,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } // 4. Add user-defined build-time variables LAST (highest priority - can override everything) + // For rollback rebuilds, use variables from the snapshot if available + $rollbackBuildVars = $this->getRollbackBuildEnvironmentVariables(); + if ($this->pull_request_id === 0) { - $sorted_environment_variables = $this->application->environment_variables() + // Use snapshot variables if this is a rollback rebuild and snapshot has build vars + $sorted_environment_variables = $rollbackBuildVars ?? $this->application->environment_variables() ->where('is_buildtime', true) // ONLY build-time variables ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->get(); @@ -1690,10 +1907,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } foreach ($sorted_environment_variables as $env) { + // For snapshot variables, use 'value' directly (already decrypted) + // For DB variables, use 'real_value' which handles decryption + $rawValue = property_exists($env, 'real_value') ? $env->real_value : ($env->value ?? ''); + // For literal/multiline vars, real_value includes quotes that we need to remove if ($env->is_literal || $env->is_multiline) { // Strip outer quotes from real_value and apply proper bash escaping - $value = trim($env->real_value, "'"); + $value = trim($rawValue, "'"); $escapedValue = escapeBashEnvValue($value); if (isDev() && isset($envs_dict[$env->key])) { @@ -1705,13 +1926,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if (isDev()) { $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); $this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline'); - $this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$env->real_value}"); $this->application_deployment_queue->addLogEntry("[DEBUG] stripped value: {$value}"); $this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}"); } } else { // For normal vars, use double quotes to allow $VAR expansion - $escapedValue = escapeBashDoubleQuoted($env->real_value); + $escapedValue = escapeBashDoubleQuoted($rawValue); if (isDev() && isset($envs_dict[$env->key])) { $this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})"); @@ -1722,7 +1942,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if (isDev()) { $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); $this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)'); - $this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$env->real_value}"); $this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}"); } } diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index a645e4f704..2767d24e0a 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -113,6 +113,7 @@ class Rollback extends Component return [ 'deployment_uuid' => $deployment->deployment_uuid, 'commit' => $deployment->commit, + 'commit_message' => $deployment->commitMessage(), 'created_at' => $deployment->created_at, 'has_config' => $hasConfig, 'image_name' => $imageName, @@ -261,6 +262,7 @@ class Rollback extends Component commit: $commit, rollback: true, force_rebuild: true, + rollback_deployment_uuid: $deploymentUuid, ); if ($result['status'] === 'queue_full') { diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 4e8eee10fc..c195c0937b 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -34,6 +34,7 @@ use OpenApi\Attributes as OA; 'destination_id' => ['type' => 'string'], 'only_this_server' => ['type' => 'boolean'], 'rollback' => ['type' => 'boolean'], + 'rollback_deployment_uuid' => ['type' => 'string', 'nullable' => true], 'commit_message' => ['type' => 'string'], ], )] diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 03c53989c9..1f6ae08452 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -11,7 +11,7 @@ use App\Models\StandaloneDocker; use Spatie\Url\Url; use Visus\Cuid2\Cuid2; -function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false) +function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $rollback_deployment_uuid = null) { $application_id = $application->id; $deployment_link = Url::fromString($application->link()."/deployment/{$deployment_uuid}"); @@ -77,6 +77,7 @@ function queue_application_deployment(Application $application, string $deployme 'restart_only' => $restart_only, 'commit' => $commit, 'rollback' => $rollback, + 'rollback_deployment_uuid' => $rollback_deployment_uuid, 'git_type' => $git_type, 'only_this_server' => $only_this_server, ]); diff --git a/database/migrations/2025_12_19_000001_add_rollback_deployment_uuid_to_application_deployment_queues.php b/database/migrations/2025_12_19_000001_add_rollback_deployment_uuid_to_application_deployment_queues.php new file mode 100644 index 0000000000..8c105fc637 --- /dev/null +++ b/database/migrations/2025_12_19_000001_add_rollback_deployment_uuid_to_application_deployment_queues.php @@ -0,0 +1,22 @@ +string('rollback_deployment_uuid')->nullable()->after('rollback'); + }); + } + + public function down(): void + { + Schema::table('application_deployment_queues', function (Blueprint $table) { + $table->dropColumn('rollback_deployment_uuid'); + }); + } +}; diff --git a/openapi.json b/openapi.json index fe8ca863e7..0a0df99ea5 100644 --- a/openapi.json +++ b/openapi.json @@ -10465,6 +10465,10 @@ "rollback": { "type": "boolean" }, + "rollback_deployment_uuid": { + "type": "string", + "nullable": true + }, "commit_message": { "type": "string" } diff --git a/openapi.yaml b/openapi.yaml index a7faa8c72e..55a7bd2551 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6637,6 +6637,9 @@ components: type: boolean rollback: type: boolean + rollback_deployment_uuid: + type: string + nullable: true commit_message: type: string type: object diff --git a/resources/views/livewire/project/application/rollback.blade.php b/resources/views/livewire/project/application/rollback.blade.php index f9d0be7e88..52ea636cdc 100644 --- a/resources/views/livewire/project/application/rollback.blade.php +++ b/resources/views/livewire/project/application/rollback.blade.php @@ -28,58 +28,75 @@
@forelse ($deployments as $deployment) -
-
-
-
- @if (data_get($deployment, 'is_current')) - - CURRENT - - @endif - - {{ Str::limit(data_get($deployment, 'commit'), 12) }} - - - ({{ data_get($deployment, 'deployment_uuid') }}) - -
- @php - $date = data_get($deployment, 'created_at'); - $interval = \Illuminate\Support\Carbon::parse($date); - @endphp -
- {{ $interval->diffForHumans() }} ({{ $interval->format('Y-m-d H:i:s') }}) -
- @if (data_get($deployment, 'image_name')) -
- Image: {{ data_get($deployment, 'image_name') }} -
- @endif +
data_get($deployment, 'is_current'), + 'border-neutral-200 dark:border-coolgray-300' => !data_get($deployment, 'is_current'), + ])> +
+ {{-- Row 1: Deployment path --}} +
+ /data/coolify/applications/{{ $application->uuid }}/deployments/{{ data_get($deployment, 'deployment_uuid') }}
-
- @if (data_get($deployment, 'image_exists')) - Image available + + {{-- Row 3: Date --}} + @php + $date = data_get($deployment, 'created_at'); + $interval = \Illuminate\Support\Carbon::parse($date); + @endphp +
+ {{ $interval->format('Y-m-d H:i:s') }} +
+ + {{-- Row 4: Image name (if available) --}} + @if (data_get($deployment, 'image_name')) +
+ Image: {{ data_get($deployment, 'image_name') }} +
+ @endif + + {{-- Row 5: Commit info (git-based) OR Deployment time (pure Dockerfile) + Rollback button --}} +
+ @if ($application->dockerfile && data_get($deployment, 'commit') === 'HEAD') + {{-- Pure Dockerfile deployment - show deployment timestamp --}} + Deployed: + {{ data_get($deployment, 'created_at')->format('Y-m-d H:i:s') }} @else - Rebuild required + {{-- Git-based deployment - show commit info --}} + Commit: + + {{ substr(data_get($deployment, 'commit'), 0, 7) }} + + @if (data_get($deployment, 'commit_message')) + - + + {{ Str::before(data_get($deployment, 'commit_message'), "\n") }} + + @endif + @endif + + @if (!data_get($deployment, 'image_exists')) + (Rebuild required) @endif @can('deploy', $application) - @if (data_get($deployment, 'is_current')) - - Current - - @elseif (data_get($deployment, 'can_instant_rollback')) + @if (data_get($deployment, 'can_instant_rollback')) - Instant Rollback + Rollback @elseif (data_get($deployment, 'can_rebuild_rollback')) - Rollback (Rebuild) + class="ml-auto" + isHighlighted> + Rollback @endif @endcan @@ -87,7 +104,7 @@
@empty -
+
No deployment history found. Deploy your application to enable rollback functionality.
@endforelse