From 439afca6429add9ff7716cfc36a02ee81c5a1882 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 5 Dec 2025 11:41:47 +0100 Subject: [PATCH] Inject commit-based image tags for Docker Compose build services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For Docker Compose applications with build directives, inject commit-based image tags (uuid_servicename:commit) to enable rollback functionality. Previously these services always used 'latest' tags, making rollback impossible. - Only injects tags for services with build: but no explicit image: - Uses pr-{id} tags for pull request deployments - Respects user-defined image: fields (preserves user intent) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/Jobs/ApplicationDeploymentJob.php | 2 +- app/Models/Application.php | 4 +- bootstrap/helpers/parsers.php | 16 +- .../Parsers/ApplicationParserImageTagTest.php | 171 ++++++++++++++++++ 4 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Parsers/ApplicationParserImageTagTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 74c26db77..6b13d2cb7 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -620,7 +620,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.'); } } else { - $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id')); + $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'), commit: $this->commit); // Always add .env file to services $services = collect(data_get($composeFile, 'services', [])); $services = $services->map(function ($service, $name) { diff --git a/app/Models/Application.php b/app/Models/Application.php index 7bddce32b..118245546 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1500,10 +1500,10 @@ class Application extends BaseModel instant_remote_process($commands, $this->destination->server, false); } - public function parse(int $pull_request_id = 0, ?int $preview_id = null) + public function parse(int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null) { if ((int) $this->compose_parsing_version >= 3) { - return applicationParser($this, $pull_request_id, $preview_id); + return applicationParser($this, $pull_request_id, $preview_id, $commit); } elseif ($this->docker_compose_raw) { return parseDockerComposeFile(resource: $this, isNew: false, pull_request_id: $pull_request_id, preview_id: $preview_id); } else { diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index e7d875777..d58a4b4fe 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -358,7 +358,7 @@ function parseDockerVolumeString(string $volumeString): array ]; } -function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null): Collection +function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null): Collection { $uuid = data_get($resource, 'uuid'); $compose = data_get($resource, 'docker_compose_raw'); @@ -1324,6 +1324,20 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int ->values(); $payload['env_file'] = $envFiles; + + // Inject commit-based image tag for services with build directive (for rollback support) + // Only inject if service has build but no explicit image defined + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + if ($hasBuild && ! $hasImage && $commit) { + $imageTag = str($commit)->substr(0, 128)->value(); + if ($isPullRequest) { + $imageTag = "pr-{$pullRequestId}"; + } + $imageRepo = "{$uuid}_{$serviceName}"; + $payload['image'] = "{$imageRepo}:{$imageTag}"; + } + if ($isPullRequest) { $serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId); } diff --git a/tests/Unit/Parsers/ApplicationParserImageTagTest.php b/tests/Unit/Parsers/ApplicationParserImageTagTest.php new file mode 100644 index 000000000..6593fa5e7 --- /dev/null +++ b/tests/Unit/Parsers/ApplicationParserImageTagTest.php @@ -0,0 +1,171 @@ + './app', + ]; + + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + $commit = 'abc123def456'; + $uuid = 'app-uuid'; + $serviceName = 'web'; + + expect($hasBuild)->toBeTrue(); + expect($hasImage)->toBeFalse(); + + // Simulate the image injection logic + if ($hasBuild && ! $hasImage && $commit) { + $imageTag = str($commit)->substr(0, 128)->value(); + $imageRepo = "{$uuid}_{$serviceName}"; + $service['image'] = "{$imageRepo}:{$imageTag}"; + } + + expect($service['image'])->toBe('app-uuid_web:abc123def456'); +}); + +it('does not inject image tag when service has explicit image directive', function () { + // User has specified their own image - we respect it + $service = [ + 'build' => './app', + 'image' => 'myregistry/myapp:latest', + ]; + + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + $commit = 'abc123def456'; + + expect($hasBuild)->toBeTrue(); + expect($hasImage)->toBeTrue(); + + // The condition should NOT trigger + $shouldInject = $hasBuild && ! $hasImage && $commit; + expect($shouldInject)->toBeFalse(); + + // Image should remain unchanged + expect($service['image'])->toBe('myregistry/myapp:latest'); +}); + +it('does not inject image tag when there is no commit', function () { + $service = [ + 'build' => './app', + ]; + + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + $commit = null; + + expect($hasBuild)->toBeTrue(); + expect($hasImage)->toBeFalse(); + + // The condition should NOT trigger (no commit) + $shouldInject = $hasBuild && ! $hasImage && $commit; + expect($shouldInject)->toBeFalse(); +}); + +it('does not inject image tag for services without build directive', function () { + // Service that pulls a pre-built image + $service = [ + 'image' => 'nginx:alpine', + ]; + + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + $commit = 'abc123def456'; + + expect($hasBuild)->toBeFalse(); + expect($hasImage)->toBeTrue(); + + // The condition should NOT trigger (no build) + $shouldInject = $hasBuild && ! $hasImage && $commit; + expect($shouldInject)->toBeFalse(); +}); + +it('uses pr-{id} tag for pull request deployments', function () { + $service = [ + 'build' => './app', + ]; + + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + $commit = 'abc123def456'; + $uuid = 'app-uuid'; + $serviceName = 'web'; + $isPullRequest = true; + $pullRequestId = 42; + + // Simulate the PR image injection logic + if ($hasBuild && ! $hasImage && $commit) { + $imageTag = str($commit)->substr(0, 128)->value(); + if ($isPullRequest) { + $imageTag = "pr-{$pullRequestId}"; + } + $imageRepo = "{$uuid}_{$serviceName}"; + $service['image'] = "{$imageRepo}:{$imageTag}"; + } + + expect($service['image'])->toBe('app-uuid_web:pr-42'); +}); + +it('truncates commit SHA to 128 characters', function () { + $service = [ + 'build' => './app', + ]; + + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + // Create a very long commit string + $commit = str_repeat('a', 200); + $uuid = 'app-uuid'; + $serviceName = 'web'; + + if ($hasBuild && ! $hasImage && $commit) { + $imageTag = str($commit)->substr(0, 128)->value(); + $imageRepo = "{$uuid}_{$serviceName}"; + $service['image'] = "{$imageRepo}:{$imageTag}"; + } + + // Tag should be exactly 128 characters + $parts = explode(':', $service['image']); + expect(strlen($parts[1]))->toBe(128); +}); + +it('handles multiple services with build directives', function () { + $services = [ + 'web' => ['build' => './web'], + 'worker' => ['build' => './worker'], + 'api' => ['build' => './api', 'image' => 'custom:tag'], // Has explicit image + 'redis' => ['image' => 'redis:alpine'], // No build + ]; + + $commit = 'abc123'; + $uuid = 'app-uuid'; + + foreach ($services as $serviceName => $service) { + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + + if ($hasBuild && ! $hasImage && $commit) { + $imageTag = str($commit)->substr(0, 128)->value(); + $imageRepo = "{$uuid}_{$serviceName}"; + $services[$serviceName]['image'] = "{$imageRepo}:{$imageTag}"; + } + } + + // web and worker should get injected images + expect($services['web']['image'])->toBe('app-uuid_web:abc123'); + expect($services['worker']['image'])->toBe('app-uuid_worker:abc123'); + + // api keeps its custom image (has explicit image) + expect($services['api']['image'])->toBe('custom:tag'); + + // redis keeps its image (no build directive) + expect($services['redis']['image'])->toBe('redis:alpine'); +});