Add encrypted env var backup in metadata.json for rebuild rollbacks

- 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 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 4.5 committed 2025-12-27 15:26:23 +01:00
1 parent b227619cbf
commit 45f931ecc8
9 files changed
+391 -65

No files matched your search

@@ -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();
}
}
+239 -20
View File
@@ -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}");
}
}
@@ -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') {
@@ -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'],
],
)]
+2 -1
View File
@@ -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,
]);
@@ -0,0 +1,22 @@
<?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('rollback_deployment_uuid')->nullable()->after('rollback');
});
}
public function down(): void
{
Schema::table('application_deployment_queues', function (Blueprint $table) {
$table->dropColumn('rollback_deployment_uuid');
});
}
};
+4
View File
@@ -10465,6 +10465,10 @@
"rollback": {
"type": "boolean"
},
"rollback_deployment_uuid": {
"type": "string",
"nullable": true
},
"commit_message": {
"type": "string"
}
+3
View File
@@ -6637,6 +6637,9 @@ components:
type: boolean
rollback:
type: boolean
rollback_deployment_uuid:
type: string
nullable: true
commit_message:
type: string
type: object
@@ -28,58 +28,75 @@
<div wire:target='loadDeployments' wire:loading.remove>
<div class="flex flex-col gap-2">
@forelse ($deployments as $deployment)
<div class="bg-white border rounded-sm dark:border-coolgray-300 dark:bg-coolgray-100 border-neutral-200 p-4">
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center gap-2">
@if (data_get($deployment, 'is_current'))
<span class="inline-flex items-center px-2 py-1 text-xs font-medium rounded-md bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
CURRENT
</span>
@endif
<span class="font-mono text-sm">
{{ Str::limit(data_get($deployment, 'commit'), 12) }}
</span>
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">
({{ data_get($deployment, 'deployment_uuid') }})
</span>
</div>
@php
$date = data_get($deployment, 'created_at');
$interval = \Illuminate\Support\Carbon::parse($date);
@endphp
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ $interval->diffForHumans() }} ({{ $interval->format('Y-m-d H:i:s') }})
</div>
@if (data_get($deployment, 'image_name'))
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono mt-1">
Image: {{ data_get($deployment, 'image_name') }}
</div>
@endif
<div @class([
'bg-white border rounded-sm dark:bg-coolgray-100 p-4 border-l-2',
'border-l-green-500' => data_get($deployment, 'is_current'),
'border-neutral-200 dark:border-coolgray-300' => !data_get($deployment, 'is_current'),
])>
<div class="flex flex-col gap-2">
{{-- Row 1: Deployment path --}}
<div class="text-xs text-neutral-500 dark:text-neutral-400 font-mono">
/data/coolify/applications/{{ $application->uuid }}/deployments/{{ data_get($deployment, 'deployment_uuid') }}
</div>
<div class="flex flex-col items-end gap-2">
@if (data_get($deployment, 'image_exists'))
<span class="text-xs text-green-600 dark:text-green-400">Image available</span>
{{-- Row 3: Date --}}
@php
$date = data_get($deployment, 'created_at');
$interval = \Illuminate\Support\Carbon::parse($date);
@endphp
<div class="text-sm text-neutral-500 dark:text-neutral-400">
{{ $interval->format('Y-m-d H:i:s') }}
</div>
{{-- Row 4: Image name (if available) --}}
@if (data_get($deployment, 'image_name'))
<div class="text-xs text-neutral-500 dark:text-neutral-400 font-mono">
Image: {{ data_get($deployment, 'image_name') }}
</div>
@endif
{{-- Row 5: Commit info (git-based) OR Deployment time (pure Dockerfile) + Rollback button --}}
<div class="flex items-center gap-2 flex-wrap">
@if ($application->dockerfile && data_get($deployment, 'commit') === 'HEAD')
{{-- Pure Dockerfile deployment - show deployment timestamp --}}
<span class="text-sm text-neutral-500 dark:text-neutral-400">Deployed:</span>
<span class="text-sm">{{ data_get($deployment, 'created_at')->format('Y-m-d H:i:s') }}</span>
@else
<span class="text-xs text-yellow-600 dark:text-yellow-400">Rebuild required</span>
{{-- Git-based deployment - show commit info --}}
<span class="text-sm text-neutral-500 dark:text-neutral-400">Commit:</span>
<a href="{{ $application->gitCommitLink(data_get($deployment, 'commit')) }}"
target="_blank"
class="underline text-sm">
{{ substr(data_get($deployment, 'commit'), 0, 7) }}
</a>
@if (data_get($deployment, 'commit_message'))
<span class="text-neutral-500 dark:text-neutral-400">-</span>
<a href="{{ $application->gitCommitLink(data_get($deployment, 'commit')) }}"
target="_blank"
class="text-neutral-500 dark:text-neutral-400 underline text-sm truncate max-w-md">
{{ Str::before(data_get($deployment, 'commit_message'), "\n") }}
</a>
@endif
@endif
@if (!data_get($deployment, 'image_exists'))
<span class="text-xs text-yellow-600 dark:text-yellow-400">(Rebuild required)</span>
@endif
@can('deploy', $application)
@if (data_get($deployment, 'is_current'))
<x-forms.button disabled tooltip="This deployment is currently running.">
Current
</x-forms.button>
@elseif (data_get($deployment, 'can_instant_rollback'))
@if (data_get($deployment, 'can_instant_rollback'))
<x-forms.button
wire:click="rollbackToDeployment('{{ data_get($deployment, 'deployment_uuid') }}')"
class="ml-auto"
isHighlighted>
Instant Rollback
Rollback
</x-forms.button>
@elseif (data_get($deployment, 'can_rebuild_rollback'))
<x-forms.button
wire:click="rollbackToDeployment('{{ data_get($deployment, 'deployment_uuid') }}')"
class="bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-700 dark:hover:bg-yellow-600">
Rollback (Rebuild)
class="ml-auto"
isHighlighted>
Rollback
</x-forms.button>
@endif
@endcan
@@ -87,7 +104,7 @@
</div>
</div>
@empty
<div class="text-gray-500 dark:text-gray-400">
<div class="text-neutral-500 dark:text-neutral-400">
No deployment history found. Deploy your application to enable rollback functionality.
</div>
@endforelse