fix(applications): allow environment variable references in image tags

Closes #10642
This commit is contained in:
Andras Bacsai
2026-10-27 15:05:23 +01:00
parent d3b1a3d405
commit 4f9ad86e52
7 changed files with 109 additions and 19 deletions
@@ -2395,7 +2395,7 @@ class ApplicationsController extends Controller
]))->setStatusCode(201);
} elseif ($type === 'dockerimage') {
$validationRules = [
'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat],
'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat(allowVariableTag: true)],
'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
];
+10 -5
View File
@@ -9,6 +9,11 @@ use Illuminate\Translation\PotentiallyTranslatedString;
class DockerImageFormat implements ValidationRule
{
/**
* @param bool $allowVariableTag Allow $VAR tags. Use it only for images that Docker Compose runs, because only Compose fills in the variables.
*/
public function __construct(private bool $allowVariableTag = false) {}
/**
* Run the validation rule.
*
@@ -35,15 +40,15 @@ class DockerImageFormat implements ValidationRule
if (preg_match('/\A(.+)@sha256:([a-f0-9]{64})\z/i', $value, $matches) === 1) {
$imageName = $matches[1];
} else {
$lastColon = strrpos($value, ':');
$lastSlash = strrpos($value, '/');
if ($lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash)) {
$imageName = substr($value, 0, $lastColon);
$tag = substr($value, $lastColon + 1);
$tagColon = strpos($value, ':', $lastSlash === false ? 0 : $lastSlash + 1);
if ($tagColon !== false) {
$imageName = substr($value, 0, $tagColon);
$tag = substr($value, $tagColon + 1);
}
}
if (! ValidationPatterns::isValidDockerImageName($imageName) || ! ValidationPatterns::isValidDockerImageTag($tag)) {
if (! ValidationPatterns::isValidDockerImageName($imageName) || ! ValidationPatterns::isValidDockerImageTag($tag, allowVariables: $this->allowVariableTag)) {
$fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.');
}
}
+8 -8
View File
@@ -20,15 +20,15 @@ class DockerImageParser
$this->tag = $matches[2];
$this->isImageHash = true;
} else {
// Split by : to handle the tag, but be careful with registry ports
$lastColon = strrpos($imageString, ':');
$hasSlash = str_contains($imageString, '/');
// The tag starts at the first colon after the last slash. A colon before
// the last slash is a registry port, and a later colon can be part of a
// ${VAR:-default} tag.
$lastSlash = strrpos($imageString, '/');
$tagColon = strpos($imageString, ':', $lastSlash === false ? 0 : $lastSlash + 1);
// If the last colon appears after the last slash, it's a tag
// Otherwise it might be a port in the registry URL
if ($lastColon !== false && (! $hasSlash || $lastColon > strrpos($imageString, '/'))) {
$mainPart = substr($imageString, 0, $lastColon);
$this->tag = substr($imageString, $lastColon + 1);
if ($tagColon !== false) {
$mainPart = substr($imageString, 0, $tagColon);
$this->tag = substr($imageString, $tagColon + 1);
// Check if the tag is a SHA256 hash
$this->isImageHash = $this->isSha256Hash($this->tag);
+19 -5
View File
@@ -144,12 +144,23 @@ class ValidationPatterns
public const DOCKER_IMAGE_NAME_PATTERN = '/\A(?=.{1,255}\z)(?:(?:(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[0-9a-f:.]+\])(?::[0-9]+)?\/)?[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*)(?:@sha256)?\z/';
/**
* Pattern for Docker image tags.
* Pattern for literal Docker image tags.
*
* Docker tags may contain letters, digits, underscores, dots, and hyphens,
* must start with an alphanumeric/underscore, and are limited to 128 chars.
*/
public const DOCKER_IMAGE_TAG_PATTERN = '/\A[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}\z/';
public const DOCKER_IMAGE_LITERAL_TAG_PATTERN = '/\A[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}\z/';
/**
* Pattern for Docker image tags of applications.
*
* Same as DOCKER_IMAGE_LITERAL_TAG_PATTERN, but
* $VAR, ${VAR}, ${VAR:-default} and ${VAR-default} references are also allowed,
* because Docker Compose fills them in from the application's environment
* variables at deploy time. A default may only hold tag characters. Other
* expansions ($(...), ${VAR:?...}, nested references, $1, $$) are rejected.
*/
public const DOCKER_IMAGE_TAG_PATTERN = '/\A(?=.{1,128}\z)(?:[A-Za-z0-9_]|\$(?:[A-Za-z_][A-Za-z0-9_]*+|\{[A-Za-z_][A-Za-z0-9_]*+(?::?-[A-Za-z0-9_.-]*+)?\}))(?:[A-Za-z0-9_.-]|\$(?:[A-Za-z_][A-Za-z0-9_]*+|\{[A-Za-z_][A-Za-z0-9_]*+(?::?-[A-Za-z0-9_.-]*+)?\}))*\z/';
/**
* Normalize environment variable keys before validation and storage.
@@ -312,7 +323,7 @@ class ValidationPatterns
{
return [
"{$nameField}.regex" => 'The Docker registry image name must be a valid image repository without a tag and may not contain shell metacharacters.',
"{$tagField}.regex" => 'The Docker registry image tag must be a valid Docker tag and may not contain shell metacharacters.',
"{$tagField}.regex" => 'The Docker registry image tag must be a valid Docker tag or a $VARIABLE reference and may not contain shell metacharacters.',
];
}
@@ -348,14 +359,17 @@ class ValidationPatterns
/**
* Check if a string is a valid Docker image tag.
*
* Set $allowVariables to false where Docker Compose does not fill in the tag,
* for example for images that Coolify runs with docker run.
*/
public static function isValidDockerImageTag(?string $value): bool
public static function isValidDockerImageTag(?string $value, bool $allowVariables = true): bool
{
if (blank($value)) {
return true;
}
return preg_match(self::DOCKER_IMAGE_TAG_PATTERN, $value) === 1;
return preg_match($allowVariables ? self::DOCKER_IMAGE_TAG_PATTERN : self::DOCKER_IMAGE_LITERAL_TAG_PATTERN, $value) === 1;
}
/**
@@ -53,3 +53,17 @@ it('uses safe docker registry image validation rules in the application general
->and($validator->errors()->has('dockerRegistryImageName'))->toBeTrue()
->and($validator->errors()->has('dockerRegistryImageTag'))->toBeTrue();
});
it('accepts an environment variable reference as the docker registry image tag in the application general form', function () {
$component = new General;
$method = new ReflectionMethod($component, 'rules');
$rules = $method->invoke($component);
$validator = validator([
'dockerRegistryImageTag' => '$API_VERSION',
], [
'dockerRegistryImageTag' => $rules['dockerRegistryImageTag'],
]);
expect($validator->passes())->toBeTrue();
});
+12
View File
@@ -139,3 +139,15 @@ it('correctly parses image when user provides digest-decorated name with colon h
->and($parser->getTag())->toBe($hash)
->and($parser->isImageHash())->toBeTrue();
});
it('parses a tag with a default value as one tag', function (string $imageString, string $imageName) {
$parser = new DockerImageParser;
$parser->parse($imageString);
expect($parser->getFullImageNameWithoutTag())->toBe($imageName)
->and($parser->getTag())->toBe('${API_VERSION:-latest}')
->and($parser->isImageHash())->toBeFalse();
})->with([
'image' => ['nginx:${API_VERSION:-latest}', 'nginx'],
'registry image with port' => ['localhost:5000/team/app:${API_VERSION:-latest}', 'localhost:5000/team/app'],
]);
@@ -46,6 +46,11 @@ it('accepts valid docker registry image tags', function (string $tag) {
'uppercase and underscore' => 'PR_123',
'sha256 hash' => '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'legacy sha256 prefixed hash' => 'sha256-1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'environment variable reference' => '$API_VERSION',
'braced environment variable reference' => '${API_VERSION}',
'environment variable reference with literal parts' => 'v${API_VERSION}-alpine',
'environment variable reference with default for empty or unset' => '${API_VERSION:-latest}',
'environment variable reference with default for unset' => '${API_VERSION-1.27-alpine}',
]);
it('rejects docker registry image tags with shell metacharacters', function (string $tag) {
@@ -57,6 +62,14 @@ it('rejects docker registry image tags with shell metacharacters', function (str
'pipe' => 'latest|id',
'logical and' => 'latest&&id',
'newline' => "latest\nid",
'bare dollar' => '$',
'positional parameter' => '$1',
'special parameter' => '$$',
'unclosed braced reference' => '${API_VERSION',
'parameter expansion with command substitution default' => '${API_VERSION:-$(id)}',
'parameter expansion with nested reference default' => '${API_VERSION:-${OTHER}}',
'parameter expansion with required error' => '${API_VERSION:?missing}',
'parameter expansion with assignment' => '${API_VERSION:=latest}',
]);
it('accepts supported full docker image reference formats', function (string $imageReference) {
@@ -76,6 +89,21 @@ it('accepts supported full docker image reference formats', function (string $im
'IPv6 registry port image with tag' => '[2a01:4f8::1]:5000/app:latest',
]);
it('accepts an environment variable tag in a full docker image reference only when variable tags are allowed', function () {
$validate = function (DockerImageFormat $rule): array {
$failures = [];
$rule->validate('image', 'ghcr.io/user/app:${API_VERSION:-latest}', function (string $message) use (&$failures): void {
$failures[] = $message;
});
return $failures;
};
expect($validate(new DockerImageFormat(allowVariableTag: true)))->toBeEmpty()
->and($validate(new DockerImageFormat))->not->toBeEmpty();
});
it('rejects unsupported full docker image reference formats', function (string $imageReference) {
$failures = [];
@@ -115,3 +143,20 @@ it('stops deployments when a stored docker registry image value is unsafe', func
expect(fn () => $method->invoke($job))->toThrow(DeploymentException::class);
});
it('allows deployments when the stored docker registry image tag is an environment variable reference', function () {
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
$jobReflection = new ReflectionClass($job);
foreach ([
'application' => new Application([
'docker_registry_image_name' => 'ghcr.io/user/app',
'docker_registry_image_tag' => '$API_VERSION',
]),
'dockerImagePreviewTag' => null,
] as $property => $value) {
$jobReflection->getProperty($property)->setValue($job, $value);
}
$jobReflection->getMethod('validateDockerRegistryImageConfiguration')->invoke($job);
})->throwsNoExceptions();