diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index f93ecf310..e9c52d2f5 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -17,6 +17,7 @@ use App\Models\Service; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; +use App\Services\DockerImageParser; use Illuminate\Http\Request; use Illuminate\Validation\Rule; use OpenApi\Attributes as OA; @@ -1512,31 +1513,32 @@ private function create_application(Request $request, $type) if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - // Process docker image name and tag for SHA256 digests + // Process docker image name and tag using DockerImageParser $dockerImageName = $request->docker_registry_image_name; $dockerImageTag = $request->docker_registry_image_tag; - // Strip 'sha256:' prefix if user provided it in the tag + // Build the full Docker image string for parsing if ($dockerImageTag) { - $dockerImageTag = preg_replace('/^sha256:/i', '', trim($dockerImageTag)); + $dockerImageString = $dockerImageName.':'.$dockerImageTag; + } else { + $dockerImageString = $dockerImageName; } - // Remove @sha256 from image name if user added it - if ($dockerImageName) { - $dockerImageName = preg_replace('/@sha256$/i', '', trim($dockerImageName)); - } + // Parse using DockerImageParser to normalize the image reference + $parser = new DockerImageParser; + $parser->parse($dockerImageString); - // Check if tag is a valid SHA256 hash (64 hex characters) - $isSha256Hash = $dockerImageTag && preg_match('/^[a-f0-9]{64}$/i', $dockerImageTag); + // Get normalized image name and tag + $normalizedImageName = $parser->getFullImageNameWithoutTag(); - // Append @sha256 to image name if using digest and not already present - if ($isSha256Hash && ! str_ends_with($dockerImageName, '@sha256')) { - $dockerImageName .= '@sha256'; + // Append @sha256 to image name if using digest + if ($parser->isImageHash() && ! str_ends_with($normalizedImageName, '@sha256')) { + $normalizedImageName .= '@sha256'; } // Set processed values back to request - $request->offsetSet('docker_registry_image_name', $dockerImageName); - $request->offsetSet('docker_registry_image_tag', $dockerImageTag ?: 'latest'); + $request->offsetSet('docker_registry_image_name', $normalizedImageName); + $request->offsetSet('docker_registry_image_tag', $parser->getTag()); $application = new Application; removeUnnecessaryFieldsFromRequest($request); diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index e105c956a..96e3dccc1 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -28,18 +28,60 @@ public function mount() $this->query = request()->query(); } + /** + * Auto-parse image name when user pastes a complete Docker image reference + * Examples: + * - nginx:stable-alpine3.21-perl@sha256:4e272eef... + * - ghcr.io/user/app:v1.2.3 + * - nginx@sha256:abc123... + */ + public function updatedImageName(): void + { + if (empty($this->imageName)) { + return; + } + + // Don't auto-parse if user has already manually filled tag or sha256 fields + if (! empty($this->imageTag) || ! empty($this->imageSha256)) { + return; + } + + // Only auto-parse if the image name contains a tag (:) or digest (@) + if (! str_contains($this->imageName, ':') && ! str_contains($this->imageName, '@')) { + return; + } + + try { + $parser = new DockerImageParser; + $parser->parse($this->imageName); + + // Extract the base image name (without tag/digest) + $baseImageName = $parser->getFullImageNameWithoutTag(); + + // Only update if parsing resulted in different base name + // This prevents unnecessary updates when user types just the name + if ($baseImageName !== $this->imageName) { + if ($parser->isImageHash()) { + // It's a SHA256 digest (takes priority over tag) + $this->imageSha256 = $parser->getTag(); + $this->imageTag = ''; + } elseif ($parser->getTag() !== 'latest' || str_contains($this->imageName, ':')) { + // It's a regular tag (only set if not default 'latest' or explicitly specified) + $this->imageTag = $parser->getTag(); + $this->imageSha256 = ''; + } + + // Update imageName to just the base name + $this->imageName = $baseImageName; + } + } catch (\Exception $e) { + // If parsing fails, leave the image name as-is + // User will see validation error on submit + } + } + public function submit() { - // Strip 'sha256:' prefix if user pasted it - if ($this->imageSha256) { - $this->imageSha256 = preg_replace('/^sha256:/i', '', trim($this->imageSha256)); - } - - // Remove @sha256 from image name if user added it - if ($this->imageName) { - $this->imageName = preg_replace('/@sha256$/i', '', trim($this->imageName)); - } - $this->validate([ 'imageName' => ['required', 'string'], 'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'], @@ -56,13 +98,16 @@ public function submit() // Build the full Docker image string if ($this->imageSha256) { - $dockerImage = $this->imageName.'@sha256:'.$this->imageSha256; + // Strip 'sha256:' prefix if user pasted it + $sha256Hash = preg_replace('/^sha256:/i', '', trim($this->imageSha256)); + $dockerImage = $this->imageName.'@sha256:'.$sha256Hash; } elseif ($this->imageTag) { $dockerImage = $this->imageName.':'.$this->imageTag; } else { $dockerImage = $this->imageName.':latest'; } + // Parse using DockerImageParser to normalize the image reference $parser = new DockerImageParser; $parser->parse($dockerImage); diff --git a/resources/views/livewire/project/new/docker-image.blade.php b/resources/views/livewire/project/new/docker-image.blade.php index 54c175b82..df50b8e21 100644 --- a/resources/views/livewire/project/new/docker-image.blade.php +++ b/resources/views/livewire/project/new/docker-image.blade.php @@ -7,8 +7,8 @@ Save
-
imageName = 'nginx:stable-alpine3.21-perl'; + $component->imageTag = ''; + $component->imageSha256 = ''; + + $component->updatedImageName(); + + expect($component->imageName)->toBe('nginx') + ->and($component->imageTag)->toBe('stable-alpine3.21-perl') + ->and($component->imageSha256)->toBe(''); +}); + +it('auto-parses complete docker image reference with sha256 digest', function () { + $hash = '4e272eef7ec6a7e76b9c521dcf14a3d397f7c370f48cbdbcfad22f041a1449cb'; + $component = new DockerImage; + $component->imageName = "nginx@sha256:{$hash}"; + $component->imageTag = ''; + $component->imageSha256 = ''; + + $component->updatedImageName(); + + expect($component->imageName)->toBe('nginx') + ->and($component->imageTag)->toBe('') + ->and($component->imageSha256)->toBe($hash); +}); + +it('auto-parses complete docker image reference with tag and sha256 digest', function () { + $hash = '4e272eef7ec6a7e76b9c521dcf14a3d397f7c370f48cbdbcfad22f041a1449cb'; + $component = new DockerImage; + $component->imageName = "nginx:stable-alpine3.21-perl@sha256:{$hash}"; + $component->imageTag = ''; + $component->imageSha256 = ''; + + $component->updatedImageName(); + + // When both tag and digest are present, Docker keeps the tag in the name + // but uses the digest for pulling. The tag becomes part of the image name. + expect($component->imageName)->toBe('nginx:stable-alpine3.21-perl') + ->and($component->imageTag)->toBe('') + ->and($component->imageSha256)->toBe($hash); +}); + +it('auto-parses registry image with port and tag', function () { + $component = new DockerImage; + $component->imageName = 'registry.example.com:5000/myapp:v1.2.3'; + $component->imageTag = ''; + $component->imageSha256 = ''; + + $component->updatedImageName(); + + expect($component->imageName)->toBe('registry.example.com:5000/myapp') + ->and($component->imageTag)->toBe('v1.2.3') + ->and($component->imageSha256)->toBe(''); +}); + +it('auto-parses ghcr image with sha256 digest', function () { + $hash = 'abc123def456789abcdef123456789abcdef123456789abcdef123456789abc1'; + $component = new DockerImage; + $component->imageName = "ghcr.io/user/app@sha256:{$hash}"; + $component->imageTag = ''; + $component->imageSha256 = ''; + + $component->updatedImageName(); + + expect($component->imageName)->toBe('ghcr.io/user/app') + ->and($component->imageTag)->toBe('') + ->and($component->imageSha256)->toBe($hash); +}); + +it('does not auto-parse if user has manually filled tag field', function () { + $component = new DockerImage; + $component->imageTag = 'latest'; // User manually set this FIRST + $component->imageSha256 = ''; + $component->imageName = 'nginx:stable'; // Then user enters image name + + $component->updatedImageName(); + + // Should not auto-parse because tag is already set + expect($component->imageName)->toBe('nginx:stable') + ->and($component->imageTag)->toBe('latest') + ->and($component->imageSha256)->toBe(''); +}); + +it('does not auto-parse if user has manually filled sha256 field', function () { + $hash = '4e272eef7ec6a7e76b9c521dcf14a3d397f7c370f48cbdbcfad22f041a1449cb'; + $component = new DockerImage; + $component->imageSha256 = $hash; // User manually set this FIRST + $component->imageTag = ''; + $component->imageName = 'nginx:stable'; // Then user enters image name + + $component->updatedImageName(); + + // Should not auto-parse because sha256 is already set + expect($component->imageName)->toBe('nginx:stable') + ->and($component->imageTag)->toBe('') + ->and($component->imageSha256)->toBe($hash); +}); + +it('does not auto-parse plain image name without tag or digest', function () { + $component = new DockerImage; + $component->imageName = 'nginx'; + $component->imageTag = ''; + $component->imageSha256 = ''; + + $component->updatedImageName(); + + // Should leave as-is since there's nothing to parse + expect($component->imageName)->toBe('nginx') + ->and($component->imageTag)->toBe('') + ->and($component->imageSha256)->toBe(''); +}); + +it('handles parsing errors gracefully', function () { + $component = new DockerImage; + $component->imageName = 'registry.io:5000/myapp:v1.2.3'; + $component->imageTag = ''; + $component->imageSha256 = ''; + + // Should not throw exception + expect(fn () => $component->updatedImageName())->not->toThrow(\Exception::class); + + // Should successfully parse this valid image + expect($component->imageName)->toBe('registry.io:5000/myapp') + ->and($component->imageTag)->toBe('v1.2.3'); +}); diff --git a/tests/Unit/DockerImageParserTest.php b/tests/Unit/DockerImageParserTest.php index 6102a90b2..43de9797f 100644 --- a/tests/Unit/DockerImageParserTest.php +++ b/tests/Unit/DockerImageParserTest.php @@ -107,3 +107,35 @@ expect($parser->isImageHash())->toBeFalse("Hash {$hash} should not be recognized as valid SHA256"); } }); + +it('correctly parses and normalizes image with full digest including hash', function () { + $parser = new DockerImageParser; + $hash = '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0'; + $parser->parse("nginx@sha256:{$hash}"); + + expect($parser->getImageName())->toBe('nginx') + ->and($parser->getTag())->toBe($hash) + ->and($parser->isImageHash())->toBeTrue() + ->and($parser->getFullImageNameWithoutTag())->toBe('nginx') + ->and($parser->toString())->toBe("nginx@sha256:{$hash}"); +}); + +it('correctly parses image when user provides digest-decorated name with colon hash', function () { + $parser = new DockerImageParser; + $hash = 'deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678'; + + // User might provide: nginx@sha256:deadbeef... + // This should be parsed correctly without duplication + $parser->parse("nginx@sha256:{$hash}"); + + $imageName = $parser->getFullImageNameWithoutTag(); + if ($parser->isImageHash() && ! str_ends_with($imageName, '@sha256')) { + $imageName .= '@sha256'; + } + + // The result should be: nginx@sha256 (name) + deadbeef... (tag) + // NOT: nginx:deadbeef...@sha256 or nginx@sha256:deadbeef...@sha256 + expect($imageName)->toBe('nginx@sha256') + ->and($parser->getTag())->toBe($hash) + ->and($parser->isImageHash())->toBeTrue(); +});