feat(services): allow variables in compose network names

Accept a top-level network `name:` that is a single Compose variable
(`${VAR}`, `${VAR:-default}`, `${VAR-default}`), e.g. for external
networks that differ per server. The default must still be a valid
network name; surrounding text, nested variables, and shell syntax
are still rejected.

When parsing services, such variables are created as service
environment variables (seeded with the default) so users can view and
change them, while the compose file keeps the variable for Compose to
resolve from .env at deployment. Existing values are preserved on
re-parse.
This commit is contained in:
Andras Bacsai
2026-09-25 21:43:35 +02:00
parent a39f3f29b6
commit 37bd776f70
3 changed files with 163 additions and 1 deletions
+52 -1
View File
@@ -99,7 +99,7 @@ function validateDockerComposeForInjection(string $composeYaml): void
validateComposeNetworkName((string) $networkName);
}
if (is_array($networkConfig) && isset($networkConfig['name']) && is_string($networkConfig['name'])) {
validateComposeNetworkName($networkConfig['name'], 'network name field');
validateComposeNetworkNameField($networkConfig['name']);
}
}
}
@@ -136,6 +136,42 @@ function validateComposeArrayVolumeSource(string $source): void
}
}
/**
* Splits a top-level network `name:` that is one whole Compose variable (`${VAR}`, `${VAR:-default}`
* or `${VAR-default}`), such as an external network that differs per server.
*
* @return array{variable: string, default: ?string}|null
*/
function composeNetworkNameVariable(string $name): ?array
{
if (preg_match('/\A\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}\z/', $name, $matches) !== 1) {
return null;
}
return ['variable' => $matches[1], 'default' => $matches[2] ?? null];
}
/**
* A network `name:` may be such a variable: only Docker Compose reads this value and it never runs a
* shell; Coolify's own network commands use the network keys. The default must still be a valid
* network name, and nothing else is allowed around the variable.
*
* @throws Exception If the value is not a valid network name or such a variable
*/
function validateComposeNetworkNameField(string $name): void
{
$variable = composeNetworkNameVariable($name);
if ($variable !== null) {
if ($variable['default'] !== null) {
validateComposeNetworkName($variable['default'], 'network name field');
}
return;
}
validateComposeNetworkName($name, 'network name field');
}
/**
* Reject Docker Compose network names that are not valid Docker identifiers.
*
@@ -1708,6 +1744,21 @@ function serviceParser(Service $resource): Collection
'configs' => collect(data_get($yaml, 'configs', [])),
'secrets' => collect(data_get($yaml, 'secrets', [])),
]);
// A network name like ${SHARED_NETWORK:-default} becomes a service variable, like variables in
// environment:, so users can see and change it. Compose resolves the name from .env at deployment.
foreach ($topLevel->get('networks') as $network) {
$variable = is_string(data_get($network, 'name')) ? composeNetworkNameVariable(data_get($network, 'name')) : null;
if ($variable !== null) {
$resource->environment_variables()->firstOrCreate([
'key' => $variable['variable'],
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $variable['default'] ?? '',
'is_preview' => false,
]);
}
}
// If there are predefined volumes, make sure they are not null
if ($topLevel->get('volumes')->count() > 0) {
$temp = collect([]);
@@ -0,0 +1,72 @@
<?php
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function makeServiceWithNetworks(string $networks): Service
{
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]);
return Service::factory()->create([
'server_id' => $server->id,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'docker_compose_raw' => "services:\n app:\n image: nginx:alpine\n networks:\n - shared\n - other\n - plain\nnetworks:\n{$networks}",
]);
}
test('variables in network names become service variables with their default', function () {
$service = makeServiceWithNetworks(<<<'YAML'
shared:
external: true
name: ${SHARED_NETWORK:-traefik_public}
other:
external: true
name: ${OTHER_NETWORK}
plain:
name: fixed-network
YAML);
$service->parse();
$variables = $service->environment_variables()->pluck('value', 'key');
expect($variables->get('SHARED_NETWORK'))->toBe('traefik_public')
->and($variables->has('OTHER_NETWORK'))->toBeTrue()
->and((string) $variables->get('OTHER_NETWORK'))->toBe('')
->and($variables->keys()->filter(fn ($key) => str_contains($key, 'fixed') || str_contains($key, 'plain')))->toBeEmpty()
// Compose resolves the name from .env at deployment, so the compose file keeps the variable.
->and($service->fresh()->docker_compose)->toContain('${SHARED_NETWORK:-traefik_public}');
});
test('a changed network variable value is kept when the compose file is parsed again', function () {
$service = makeServiceWithNetworks(<<<'YAML'
shared:
external: true
name: ${SHARED_NETWORK-traefik-public}
other:
name: other-network
plain:
name: plain-network
YAML);
$service->parse();
expect($service->environment_variables()->where('key', 'SHARED_NETWORK')->value('value'))->toBe('traefik-public');
$service->environment_variables()->where('key', 'SHARED_NETWORK')->firstOrFail()->update(['value' => 'my_shared']);
$service->fresh()->parse();
expect($service->environment_variables()->where('key', 'SHARED_NETWORK')->count())->toBe(1)
->and($service->environment_variables()->where('key', 'SHARED_NETWORK')->value('value'))->toBe('my_shared');
});
+39
View File
@@ -269,3 +269,42 @@ YAML;
expect(fn () => validateDockerComposeForInjection($validCompose))
->not->toThrow(Exception::class);
});
test('validateDockerComposeForInjection allows variables in compose network name fields', function (string $name) {
$compose = <<<YAML
services:
app:
image: nginx:latest
networks:
- shared
networks:
shared:
external: true
name: '{$name}'
YAML;
expect(fn () => validateDockerComposeForInjection($compose))->not->toThrow(Exception::class);
})->with([
'variable' => ['${SHARED_NETWORK}'],
'variable with a default' => ['${SHARED_NETWORK:-traefik_public}'],
'variable with an unset-only default' => ['${SHARED_NETWORK-traefik-public.1}'],
]);
test('validateDockerComposeForInjection still blocks unsafe compose network names with variables', function (string $name, string $where) {
$networkKey = $where === 'key' ? $name : 'shared';
$nameField = $where === 'name' ? $name : 'shared';
$compose = "services:\n app:\n image: nginx:latest\nnetworks:\n ".json_encode($networkKey).":\n name: ".json_encode($nameField)."\n";
expect(fn () => validateDockerComposeForInjection($compose))->toThrow(Exception::class, 'Invalid Docker Compose network name');
})->with([
'default with shell characters' => ['${NET:-bad name;id}', 'name'],
'default with command substitution' => ['${NET:-$(id)}', 'name'],
'command substitution' => ['$(id)', 'name'],
'backticks' => ['`id`', 'name'],
'text around the variable' => ['prefix_${NET}', 'name'],
'nested variable' => ['${NET:-${OTHER}}', 'name'],
'invalid variable name' => ['${1NET}', 'name'],
'required-variable form' => ['${NET:?missing}', 'name'],
'newline' => ["\${NET}\nid", 'name'],
'variable as network key' => ['${NET}', 'key'],
]);