enhancement: allow deploy from container image hash

This commit is contained in:
saurabhraghuvanshii 2025-08-28 02:10:14 +05:30
parent 886f910632
commit 3c126927d5
6 changed files with 145 additions and 59 deletions

View file

@ -410,7 +410,12 @@ private function deploy_dockerimage_buildpack()
} else {
$this->dockerImageTag = $this->application->docker_registry_image_tag;
}
$this->application_deployment_queue->addLogEntry("Starting deployment of {$this->dockerImage}:{$this->dockerImageTag} to {$this->server->name}.");
// Check if this is an image hash deployment
$isImageHash = str($this->dockerImageTag)->startsWith('sha256-');
$displayName = $isImageHash ? "{$this->dockerImage}@sha256:".str($this->dockerImageTag)->after('sha256-') : "{$this->dockerImage}:{$this->dockerImageTag}";
$this->application_deployment_queue->addLogEntry("Starting deployment of {$displayName} to {$this->server->name}.");
$this->generate_image_names();
$this->prepare_builder_image();
$this->generate_compose_file();
@ -801,7 +806,13 @@ private function generate_image_names()
$this->production_image_name = "{$this->application->uuid}:latest";
}
} elseif ($this->application->build_pack === 'dockerimage') {
$this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}";
// Check if this is an image hash deployment
if (str($this->dockerImageTag)->startsWith('sha256-')) {
$hash = str($this->dockerImageTag)->after('sha256-');
$this->production_image_name = "{$this->dockerImage}@sha256:{$hash}";
} else {
$this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}";
}
} elseif ($this->pull_request_id !== 0) {
if ($this->application->docker_registry_image_name) {
$this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build";

View file

@ -45,6 +45,10 @@ public function submit()
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
// Determine the image tag based on whether it's a hash or regular tag
$imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
$application = Application::create([
'name' => 'docker-image-'.new Cuid2,
'repository_project_id' => 0,

View file

@ -10,6 +10,8 @@ class DockerImageParser
private string $tag = 'latest';
private bool $isImageHash = false;
public function parse(string $imageString): self
{
// First split by : to handle the tag, but be careful with registry ports
@ -21,9 +23,13 @@ public function parse(string $imageString): self
if ($lastColon !== false && (! $hasSlash || $lastColon > strrpos($imageString, '/'))) {
$mainPart = substr($imageString, 0, $lastColon);
$this->tag = substr($imageString, $lastColon + 1);
// Check if the tag is a SHA256 hash
$this->isImageHash = $this->isSha256Hash($this->tag);
} else {
$mainPart = $imageString;
$this->tag = 'latest';
$this->isImageHash = false;
}
// Split the main part by / to handle registry and image name
@ -41,6 +47,37 @@ public function parse(string $imageString): self
return $this;
}
/**
* Check if the given string is a SHA256 hash
*/
private function isSha256Hash(string $hash): bool
{
// SHA256 hashes are 64 characters long and contain only hexadecimal characters
return preg_match('/^[a-f0-9]{64}$/i', $hash) === 1;
}
/**
* Check if the current tag is an image hash
*/
public function isImageHash(): bool
{
return $this->isImageHash;
}
/**
* Get the full image name with hash if present
*/
public function getFullImageNameWithHash(): string
{
$imageName = $this->getFullImageNameWithoutTag();
if ($this->isImageHash) {
return $imageName.'@sha256:'.$this->tag;
}
return $imageName.':'.$this->tag;
}
public function getFullImageNameWithoutTag(): string
{
if ($this->registryUrl) {
@ -73,6 +110,10 @@ public function toString(): string
}
$parts[] = $this->imageName;
if ($this->isImageHash) {
return implode('/', $parts).'@sha256:'.$this->tag;
}
return implode('/', $parts).':'.$this->tag;
}
}

View file

@ -163,12 +163,14 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@if ($application->destination->server->isSwarm())
<x-forms.input required id="application.docker_registry_image_name" label="Docker Image"
x-bind:disabled="!canUpdate" />
<x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag"
<x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag or Hash"
helper="Enter a tag (e.g., 'latest', 'v1.2.3') or SHA256 hash (e.g., 'sha256-59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0')"
x-bind:disabled="!canUpdate" />
@else
<x-forms.input id="application.docker_registry_image_name" label="Docker Image"
x-bind:disabled="!canUpdate" />
<x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag"
<x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag or Hash"
helper="Enter a tag (e.g., 'latest', 'v1.2.3') or SHA256 hash (e.g., 'sha256-59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0')"
x-bind:disabled="!canUpdate" />
@endif
@else

View file

@ -6,6 +6,13 @@
<h2>Docker Image</h2>
<x-forms.button type="submit">Save</x-forms.button>
</div>
<x-forms.input rows="20" id="dockerImage" placeholder="nginx:latest"></x-forms.textarea>
<div class="space-y-4">
<x-forms.textarea
id="dockerImage"
placeholder="nginx:latest or ghcr.io/benjaminehowe/rail-disruptions:sha256-59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0"
helper="Enter a Docker image with tag or SHA256 hash. Examples:<br>• nginx:latest<br>• ghcr.io/user/app:v1.2.3<br>• sha256-59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0"
rows="3"
/>
</div>
</form>
</div>

View file

@ -3,92 +3,113 @@
namespace Tests\Unit;
use App\Services\DockerImageParser;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Tests\TestCase;
class DockerImageParserTest extends TestCase
{
private DockerImageParser $parser;
protected function setUp(): void
public function test_parses_regular_image_with_tag()
{
parent::setUp();
$this->parser = new DockerImageParser;
$parser = new DockerImageParser;
$parser->parse('nginx:latest');
$this->assertEquals('nginx', $parser->getImageName());
$this->assertEquals('latest', $parser->getTag());
$this->assertFalse($parser->isImageHash());
$this->assertEquals('nginx:latest', $parser->toString());
}
#[Test]
public function it_parses_simple_image_name()
public function test_parses_image_with_sha256_hash()
{
$this->parser->parse('nginx');
$parser = new DockerImageParser;
$hash = '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0';
$parser->parse("ghcr.io/benjaminehowe/rail-disruptions:{$hash}");
$this->assertEquals('', $this->parser->getRegistryUrl());
$this->assertEquals('nginx', $this->parser->getImageName());
$this->assertEquals('latest', $this->parser->getTag());
$this->assertEquals('ghcr.io/benjaminehowe/rail-disruptions', $parser->getFullImageNameWithoutTag());
$this->assertEquals($hash, $parser->getTag());
$this->assertTrue($parser->isImageHash());
$this->assertEquals("ghcr.io/benjaminehowe/rail-disruptions@sha256:{$hash}", $parser->toString());
$this->assertEquals("ghcr.io/benjaminehowe/rail-disruptions@sha256:{$hash}", $parser->getFullImageNameWithHash());
}
#[Test]
public function it_parses_image_with_tag()
public function test_parses_registry_image_with_hash()
{
$this->parser->parse('nginx:1.19');
$parser = new DockerImageParser;
$hash = 'abc123def456789abcdef123456789abcdef123456789abcdef123456789abc1';
$parser->parse("docker.io/library/nginx:{$hash}");
$this->assertEquals('', $this->parser->getRegistryUrl());
$this->assertEquals('nginx', $this->parser->getImageName());
$this->assertEquals('1.19', $this->parser->getTag());
$this->assertEquals('docker.io/library/nginx', $parser->getFullImageNameWithoutTag());
$this->assertEquals($hash, $parser->getTag());
$this->assertTrue($parser->isImageHash());
$this->assertEquals("docker.io/library/nginx@sha256:{$hash}", $parser->toString());
}
#[Test]
public function it_parses_image_with_organization()
public function test_parses_image_without_tag_defaults_to_latest()
{
$this->parser->parse('coollabs/coolify:latest');
$parser = new DockerImageParser;
$parser->parse('nginx');
$this->assertEquals('', $this->parser->getRegistryUrl());
$this->assertEquals('coollabs/coolify', $this->parser->getImageName());
$this->assertEquals('latest', $this->parser->getTag());
$this->assertEquals('nginx', $parser->getImageName());
$this->assertEquals('latest', $parser->getTag());
$this->assertFalse($parser->isImageHash());
$this->assertEquals('nginx:latest', $parser->toString());
}
#[Test]
public function it_parses_image_with_registry_url()
public function test_parses_registry_with_port()
{
$this->parser->parse('ghcr.io/coollabs/coolify:v4');
$parser = new DockerImageParser;
$parser->parse('registry.example.com:5000/myapp:latest');
$this->assertEquals('ghcr.io', $this->parser->getRegistryUrl());
$this->assertEquals('coollabs/coolify', $this->parser->getImageName());
$this->assertEquals('v4', $this->parser->getTag());
$this->assertEquals('registry.example.com:5000/myapp', $parser->getFullImageNameWithoutTag());
$this->assertEquals('latest', $parser->getTag());
$this->assertFalse($parser->isImageHash());
}
#[Test]
public function it_parses_image_with_port_in_registry()
public function test_parses_registry_with_port_and_hash()
{
$this->parser->parse('localhost:5000/my-app:dev');
$parser = new DockerImageParser;
$hash = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
$parser->parse("registry.example.com:5000/myapp:{$hash}");
$this->assertEquals('localhost:5000', $this->parser->getRegistryUrl());
$this->assertEquals('my-app', $this->parser->getImageName());
$this->assertEquals('dev', $this->parser->getTag());
$this->assertEquals('registry.example.com:5000/myapp', $parser->getFullImageNameWithoutTag());
$this->assertEquals($hash, $parser->getTag());
$this->assertTrue($parser->isImageHash());
$this->assertEquals("registry.example.com:5000/myapp@sha256:{$hash}", $parser->toString());
}
#[Test]
public function it_parses_image_without_tag()
public function test_identifies_valid_sha256_hashes()
{
$this->parser->parse('ghcr.io/coollabs/coolify');
$parser = new DockerImageParser;
$this->assertEquals('ghcr.io', $this->parser->getRegistryUrl());
$this->assertEquals('coollabs/coolify', $this->parser->getImageName());
$this->assertEquals('latest', $this->parser->getTag());
// Valid SHA256 hashes
$validHashes = [
'59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0',
'1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
];
foreach ($validHashes as $hash) {
$parser->parse("image:{$hash}");
$this->assertTrue($parser->isImageHash(), "Hash {$hash} should be recognized as valid SHA256");
}
}
#[Test]
public function it_converts_back_to_string()
public function test_identifies_invalid_sha256_hashes()
{
$originalString = 'ghcr.io/coollabs/coolify:v4';
$this->parser->parse($originalString);
$parser = new DockerImageParser;
$this->assertEquals($originalString, $this->parser->toString());
}
// Invalid SHA256 hashes
$invalidHashes = [
'latest',
'v1.2.3',
'abc123', // too short
'59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf', // too short
'59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf00', // too long
'59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cfg0', // invalid char
];
#[Test]
public function it_converts_to_string_with_default_tag()
{
$this->parser->parse('nginx');
$this->assertEquals('nginx:latest', $this->parser->toString());
foreach ($invalidHashes as $hash) {
$parser->parse("image:{$hash}");
$this->assertFalse($parser->isImageHash(), "Hash {$hash} should not be recognized as valid SHA256");
}
}
}