Inject commit-based image tags for Docker Compose build services

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 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai 2025-12-05 11:41:47 +01:00
parent 710dc3ca4b
commit 439afca642
4 changed files with 189 additions and 4 deletions

View File

@ -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) {

View File

@ -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 {

View File

@ -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);
}

View File

@ -0,0 +1,171 @@
<?php
/**
* Tests for Docker Compose image tag injection in applicationParser.
*
* These tests verify the logic for injecting commit-based image tags
* into Docker Compose services with build directives.
*/
it('injects image tag for services with build but no image directive', function () {
// Test the condition: hasBuild && !hasImage && commit
$service = [
'build' => './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');
});