mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-27 17:55:59 -04:00
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:
@@ -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');
|
||||
});
|
||||
@@ -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'],
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user