diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index c0fc2d731b..efec4f4e28 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -151,6 +151,38 @@ function composeNetworkNameVariable(string $name): ?array return ['variable' => $matches[1], 'default' => $matches[2] ?? null]; } +/** + * Creates a resource environment variable for each top-level network `name:` that is one Compose + * variable, like variables in `environment:`, so users can see and change it. The compose file keeps + * the variable and Compose resolves it from the deployment `.env`. The default from the compose file + * (or an empty value) is only the first value: a value that the user changed is kept. A variable with + * a default that is not a valid network name is not created. + * + * For applications, the EnvironmentVariable `created` hook adds the preview copy. + */ +function ensureComposeNetworkNameVariables(Application|Service $resource, iterable $networks): void +{ + foreach ($networks as $network) { + $name = data_get($network, 'name'); + $variable = is_string($name) ? composeNetworkNameVariable($name) : null; + if ($variable === null) { + continue; + } + if ($variable['default'] !== null && ! ValidationPatterns::isValidDockerNetwork($variable['default'])) { + continue; + } + + $resource->environment_variables()->firstOrCreate([ + 'key' => $variable['variable'], + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $variable['default'] ?? '', + 'is_preview' => false, + ]); + } +} + /** * 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 @@ -566,6 +598,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'configs' => collect(data_get($yaml, 'configs', [])), 'secrets' => collect(data_get($yaml, 'secrets', [])), ]); + ensureComposeNetworkNameVariables($resource, $topLevel->get('networks')); // If there are predefined volumes, make sure they are not null if ($topLevel->get('volumes')->count() > 0) { $temp = collect([]); @@ -1750,21 +1783,7 @@ 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, - ]); - } - } + ensureComposeNetworkNameVariables($resource, $topLevel->get('networks')); // If there are predefined volumes, make sure they are not null if ($topLevel->get('volumes')->count() > 0) { $temp = collect([]); diff --git a/tests/Feature/ApplicationNetworkNameVariableTest.php b/tests/Feature/ApplicationNetworkNameVariableTest.php new file mode 100644 index 0000000000..0a5bf190ec --- /dev/null +++ b/tests/Feature/ApplicationNetworkNameVariableTest.php @@ -0,0 +1,213 @@ +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 Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'build_pack' => 'dockercompose', + 'compose_parsing_version' => '5', + 'docker_compose_raw' => "services:\n app:\n image: nginx:alpine\n networks:\n - shared\n - other\n - plain\nnetworks:\n{$networks}", + ]); +} + +/** + * Builds the lines of a Compose `.env` file of a deployment: the runtime `.env` next to the compose + * file (used by `docker compose up`) or the build-time `.env` (used by `docker compose build`). + * + * @return Collection + */ +function composeNetworkDeploymentEnv(Application $application, int $pullRequestId = 0, string $method = 'generate_runtime_environment_variables'): Collection +{ + $job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor(); + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + + $queue = Mockery::mock(ApplicationDeploymentQueue::class); + $queue->shouldReceive('addLogEntry')->andReturnNull(); + + $properties = [ + 'application' => $application->fresh(), + 'application_deployment_queue' => $queue, + 'build_pack' => 'dockercompose', + 'mainServer' => $application->destination->server, + 'pull_request_id' => $pullRequestId, + 'commit' => 'HEAD', + 'container_name' => 'network-variable-app', + 'saved_outputs' => collect(), + ]; + if ($pullRequestId !== 0) { + $properties['preview'] = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => $pullRequestId, + 'pull_request_html_url' => 'https://example.com/pr/'.$pullRequestId, + 'fqdn' => 'https://preview.example.com', + ]); + } + foreach ($properties as $property => $value) { + $reflection->getProperty($property)->setValue($job, $value); + } + + return collect($reflection->getMethod($method)->invoke($job)); +} + +test('variables in network names become application variables with their default', function () { + $application = makeComposeApplicationWithNetworks(<<<'YAML' + shared: + external: true + name: ${SHARED_NETWORK:-traefik_public} + other: + external: true + name: ${OTHER_NETWORK} + plain: + name: fixed-network +YAML); + + $application->parse(); + + $variables = $application->environment_variables()->get()->keyBy('key'); + + expect($variables->get('SHARED_NETWORK')?->value)->toBe('traefik_public') + ->and($variables->has('OTHER_NETWORK'))->toBeTrue() + ->and((string) $variables->get('OTHER_NETWORK')->value)->toBe('') + ->and($variables->get('SHARED_NETWORK')->is_runtime)->toBeTrue() + ->and($variables->get('SHARED_NETWORK')->is_buildtime)->toBeTrue() + ->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($application->fresh()->docker_compose)->toContain('${SHARED_NETWORK:-traefik_public}') + ->and($application->fresh()->docker_compose)->toContain('${OTHER_NETWORK}'); +}); + +test('network name variables also exist as preview variables of the application', function () { + $application = makeComposeApplicationWithNetworks(<<<'YAML' + shared: + external: true + name: ${SHARED_NETWORK:-traefik_public} + other: + name: other-network + plain: + name: plain-network +YAML); + + $application->parse(); + + $previewVariables = $application->environment_variables_preview()->where('key', 'SHARED_NETWORK')->get(); + + expect($previewVariables)->toHaveCount(1) + ->and($previewVariables->first()->value)->toBe('traefik_public'); +}); + +test('a changed application network variable value is kept when the compose file is parsed again', function () { + $application = makeComposeApplicationWithNetworks(<<<'YAML' + shared: + external: true + name: ${SHARED_NETWORK-traefik-public} + other: + name: other-network + plain: + name: plain-network +YAML); + + $application->parse(); + expect($application->environment_variables()->where('key', 'SHARED_NETWORK')->value('value'))->toBe('traefik-public'); + + $application->environment_variables()->where('key', 'SHARED_NETWORK')->firstOrFail()->update(['value' => 'my_shared']); + $application->fresh()->parse(); + + expect($application->environment_variables()->where('key', 'SHARED_NETWORK')->count())->toBe(1) + ->and($application->environment_variables()->where('key', 'SHARED_NETWORK')->value('value'))->toBe('my_shared') + ->and($application->environment_variables_preview()->where('key', 'SHARED_NETWORK')->count())->toBe(1); +}); + +test('plain network names do not create application variables', function () { + $application = makeComposeApplicationWithNetworks(<<<'YAML' + shared: + external: true + name: traefik_public + other: + name: other-network + plain: +YAML); + + $application->parse(); + + expect($application->environment_variables()->count())->toBe(0) + ->and($application->environment_variables_preview()->count())->toBe(0); +}); + +test('network name variables with an invalid default are not created', function () { + $application = makeComposeApplicationWithNetworks(<<<'YAML' + shared: + external: true + name: ${SHARED_NETWORK:-bad name;id} + other: + name: other-network + plain: + name: plain-network +YAML); + + $application->parse(); + + expect($application->environment_variables()->where('key', 'SHARED_NETWORK')->exists())->toBeFalse(); +}); + +test('the deployment .env of a compose application contains the network name variable', function () { + $application = makeComposeApplicationWithNetworks(<<<'YAML' + shared: + external: true + name: ${SHARED_NETWORK:-traefik_public} + other: + external: true + name: ${OTHER_NETWORK} + plain: + name: plain-network +YAML); + + $application->parse(); + $application->environment_variables()->where('key', 'OTHER_NETWORK')->firstOrFail()->update(['value' => 'edge_net']); + + $env = composeNetworkDeploymentEnv($application); + + expect($env)->toContain('SHARED_NETWORK=traefik_public') + ->and($env)->toContain('OTHER_NETWORK=edge_net'); + + $buildEnv = composeNetworkDeploymentEnv($application, method: 'generate_buildtime_environment_variables')->implode("\n"); + + expect($buildEnv)->toMatch('/^SHARED_NETWORK=.*traefik_public/m') + ->and($buildEnv)->toMatch('/^OTHER_NETWORK=.*edge_net/m'); +}); + +test('the preview deployment .env of a compose application contains the network name variable', function () { + $application = makeComposeApplicationWithNetworks(<<<'YAML' + shared: + external: true + name: ${SHARED_NETWORK:-traefik_public} + other: + name: other-network + plain: + name: plain-network +YAML); + + $application->parse(); + + expect(composeNetworkDeploymentEnv($application, 7))->toContain('SHARED_NETWORK=traefik_public'); +});