diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 9037fa3e5..5a00a2dd6 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -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";
diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php
index 7d68ce068..d78c61904 100644
--- a/app/Livewire/Project/New/DockerImage.php
+++ b/app/Livewire/Project/New/DockerImage.php
@@ -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,
diff --git a/app/Services/DockerImageParser.php b/app/Services/DockerImageParser.php
index 1fd6625b3..1dd34c713 100644
--- a/app/Services/DockerImageParser.php
+++ b/app/Services/DockerImageParser.php
@@ -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;
}
}
diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php
index b833fc7bb..398e94191 100644
--- a/resources/views/livewire/project/application/general.blade.php
+++ b/resources/views/livewire/project/application/general.blade.php
@@ -163,12 +163,14 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@if ($application->destination->server->isSwarm())
-
@else
-
@endif
@else
diff --git a/resources/views/livewire/project/new/docker-image.blade.php b/resources/views/livewire/project/new/docker-image.blade.php
index 4cc86710a..af1005a88 100644
--- a/resources/views/livewire/project/new/docker-image.blade.php
+++ b/resources/views/livewire/project/new/docker-image.blade.php
@@ -6,6 +6,13 @@
Docker Image
Save
-
+
+
+
diff --git a/tests/Unit/DockerImageParserTest.php b/tests/Unit/DockerImageParserTest.php
index 35dffbab4..f41a9b170 100644
--- a/tests/Unit/DockerImageParserTest.php
+++ b/tests/Unit/DockerImageParserTest.php
@@ -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");
+ }
}
}