enhancement: allow deploy from container image hash
This commit is contained in:
parent
886f910632
commit
3c126927d5
6 changed files with 145 additions and 59 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue