diff --git a/README.md b/README.md index d3fa07fb71..923b157b05 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,6 @@ Thank you so much! Piloterr Alexey Panteleev SummYT - YouTube Summarizer -OpenElements Xaman Monadical Magic as a Service diff --git a/app/Actions/Database/StartDatabaseImport.php b/app/Actions/Database/StartDatabaseImport.php index 5bdf249a8d..08bc78b0d2 100644 --- a/app/Actions/Database/StartDatabaseImport.php +++ b/app/Actions/Database/StartDatabaseImport.php @@ -194,10 +194,10 @@ class StartDatabaseImport throw new DatabaseImportException('The S3 backup was not found or exceeds the 10 GiB limit.'); } $helper = "s3-restore-{$operation}"; - $this->startS3Helper($storage, $server, $helper, $network); + $resolveOptions = $this->startS3Helper($storage, $server, $helper, $network); $sourceArg = escapeshellarg("s3temp/{$storage->bucket}/{$key}"); $commandList = [ - 'docker exec '.escapeshellarg($helper).' mc cp '.$sourceArg.' /tmp/restore', + 'docker exec '.escapeshellarg($helper).' mc cp'.$resolveOptions.' '.$sourceArg.' /tmp/restore', ...$this->streamFromHelper($helper, $container, $containerPath), 'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true', ]; @@ -329,10 +329,12 @@ class StartDatabaseImport * Starts the S3 restore helper and sets its `s3temp` mc alias. The S3 keys go to mc through the * stdin of a separate SSH process, so they are not in the arguments of any process on Coolify or * on the server. The helper is removed when either step fails. + * + * @return string The mc `--resolve` option that pins the endpoint host. */ - private function startS3Helper(S3Storage $storage, Server $server, string $helper, string $network): void + private function startS3Helper(S3Storage $storage, Server $server, string $helper, string $network): string { - $image = escapeshellarg(coolifyHelperImage().':'.getHelperVersion()); + $image = coolifyHelperImage().':'.getHelperVersion(); try { instant_remote_process([ @@ -340,9 +342,12 @@ class StartDatabaseImport 'docker run -d --network '.escapeshellarg($network) .' --name '.escapeshellarg($helper) .' '.$storage->minioClientRegionOption() - .' '.$image.' sleep 86400', + .' '.escapeshellarg($image).' sleep 86400', ], $server); - $this->setMinioClientAlias($storage, $server, $helper); + $resolveOptions = $storage->minioClientResolveOptions($server, $image, $network); + $this->setMinioClientAlias($storage, $server, $helper, $resolveOptions); + + return $resolveOptions; } catch (Throwable) { instant_remote_process(['docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true'], $server, throwError: false); @@ -350,10 +355,10 @@ class StartDatabaseImport } } - private function setMinioClientAlias(S3Storage $storage, Server $server, string $helper): void + private function setMinioClientAlias(S3Storage $storage, Server $server, string $helper, string $resolveOptions): void { $remoteCommand = ($server->isNonRoot() ? 'sudo ' : '').'docker exec -i '.escapeshellarg($helper) - .' mc alias set'.$storage->minioClientAliasOptions().' s3temp '.escapeshellarg((string) $storage->endpoint).' >/dev/null'; + .' mc alias set'.$resolveOptions.$storage->minioClientAliasOptions().' s3temp '.escapeshellarg((string) $storage->endpoint).' >/dev/null'; $timeout = (int) config('constants.ssh.command_timeout'); SshRetryHandler::retry( diff --git a/app/Http/Controllers/Api/ServerTransferController.php b/app/Http/Controllers/Api/ServerTransferController.php index efd8f428b7..0dc0229a30 100644 --- a/app/Http/Controllers/Api/ServerTransferController.php +++ b/app/Http/Controllers/Api/ServerTransferController.php @@ -232,7 +232,7 @@ class ServerTransferController extends Controller Server::throttleValidation($teamId, $ip); $probeIp = Server::normalizeIp($ip); if (isCloud() && ! filter_var($probeIp, FILTER_VALIDATE_IP)) { - $probeIp = gethostbyname($probeIp); + $probeIp = resolveHostToIp($probeIp); } $probeHost = filter_var($probeIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? "[{$probeIp}]" : $probeIp; $probeAllowed = ! isCloud() || (filter_var($probeIp, FILTER_VALIDATE_IP) diff --git a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php index 6429bb9c9c..e3951796d7 100644 --- a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php +++ b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php @@ -13,6 +13,32 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; +#[OA\Schema( + schema: 'SharedEnvironmentVariableCreateRequest', + type: 'object', + required: ['key'], + properties: [ + new OA\Property(property: 'key', type: 'string'), + new OA\Property(property: 'value', type: 'string', nullable: true), + new OA\Property(property: 'is_literal', type: 'boolean'), + new OA\Property(property: 'is_multiline', type: 'boolean'), + new OA\Property(property: 'is_shown_once', type: 'boolean', description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'), + new OA\Property(property: 'comment', type: 'string', maxLength: 256, nullable: true), + ], +)] +#[OA\Schema( + schema: 'SharedEnvironmentVariableUpdateRequest', + description: 'Send at least one field.', + type: 'object', + properties: [ + new OA\Property(property: 'key', type: 'string'), + new OA\Property(property: 'value', type: 'string', nullable: true), + new OA\Property(property: 'is_literal', type: 'boolean'), + new OA\Property(property: 'is_multiline', type: 'boolean'), + new OA\Property(property: 'is_shown_once', type: 'boolean', description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'), + new OA\Property(property: 'comment', type: 'string', maxLength: 256, nullable: true), + ], +)] class SharedEnvironmentVariablesController extends Controller { private const ALLOWED_FIELDS = ['key', 'value', 'is_literal', 'is_multiline', 'is_shown_once', 'comment']; @@ -350,20 +376,7 @@ class SharedEnvironmentVariablesController extends Controller operationId: 'create-team-shared-env', security: [['bearerAuth' => []]], tags: ['Shared Environment Variables'], - requestBody: new OA\RequestBody( - required: true, - content: new OA\JsonContent( - required: ['key'], - properties: [ - new OA\Property(property: 'key', type: 'string'), - new OA\Property(property: 'value', type: 'string', nullable: true), - new OA\Property(property: 'is_literal', type: 'boolean'), - new OA\Property(property: 'is_multiline', type: 'boolean'), - new OA\Property(property: 'is_shown_once', type: 'boolean', description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'), - new OA\Property(property: 'comment', type: 'string', nullable: true), - ], - ), - ), + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest')), responses: [ new OA\Response(response: 201, description: 'Environment variable created.'), new OA\Response(response: 401, ref: '#/components/responses/401'), @@ -391,6 +404,7 @@ class SharedEnvironmentVariablesController extends Controller parameters: [ new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')), ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest')), responses: [ new OA\Response(response: 200, description: 'Environment variable updated.'), new OA\Response(response: 401, ref: '#/components/responses/401'), @@ -479,6 +493,7 @@ class SharedEnvironmentVariablesController extends Controller parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest')), responses: [ new OA\Response(response: 201, description: 'Environment variable created.'), new OA\Response(response: 401, ref: '#/components/responses/401'), @@ -515,6 +530,7 @@ class SharedEnvironmentVariablesController extends Controller new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')), ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest')), responses: [ new OA\Response(response: 200, description: 'Environment variable updated.'), new OA\Response(response: 401, ref: '#/components/responses/401'), @@ -636,6 +652,7 @@ class SharedEnvironmentVariablesController extends Controller new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')), ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest')), responses: [ new OA\Response(response: 201, description: 'Environment variable created.'), new OA\Response(response: 401, ref: '#/components/responses/401'), @@ -678,6 +695,7 @@ class SharedEnvironmentVariablesController extends Controller new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')), ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest')), responses: [ new OA\Response(response: 200, description: 'Environment variable updated.'), new OA\Response(response: 401, ref: '#/components/responses/401'), @@ -803,6 +821,7 @@ class SharedEnvironmentVariablesController extends Controller parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest')), responses: [ new OA\Response(response: 201, description: 'Environment variable created.'), new OA\Response(response: 401, ref: '#/components/responses/401'), @@ -839,6 +858,7 @@ class SharedEnvironmentVariablesController extends Controller new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')), ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest')), responses: [ new OA\Response(response: 200, description: 'Environment variable updated.'), new OA\Response(response: 401, ref: '#/components/responses/401'), diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index cca93293ac..f95a85a87a 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -231,6 +231,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private Collection|string $build_secrets; + /** + * Build-time variables that the helper container has in its environment for build secrets. + * + * @var array + */ + private array $helperBuildSecretEnvironmentKeys = []; + public function tags() { // Do not remove this one, it needs to properly identify which worker is running the job @@ -1086,7 +1093,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $command = serverDockerConfigPrefix($this->server)."{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable {$services}"; } else { $workdir = $this->workdir; - $command = executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable {$services}"); + $command = executeInDocker($this->deployment_uuid, $this->withoutHelperBuildSecretEnvironment("{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable {$services}")); } $this->execute_remote_command([ @@ -1137,7 +1144,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue try { $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], + [executeInDocker($this->deployment_uuid, $this->withoutHelperBuildSecretEnvironment("cd {$this->workdir} && {$start_command}")), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], ); } catch (\RuntimeException $e) { if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) { @@ -1182,7 +1189,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ); } else { $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], + [executeInDocker($this->deployment_uuid, $this->withoutHelperBuildSecretEnvironment("cd {$this->basedir} && {$start_command}")), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], ); } } else { @@ -1201,7 +1208,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $command .= " --env-file {$this->workdir}/.env"; $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d"; $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, $command), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], + [executeInDocker($this->deployment_uuid, $this->withoutHelperBuildSecretEnvironment($command)), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], ); $this->write_deployment_configurations(); } @@ -1938,22 +1945,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($this->pull_request_id === 0) { // Generate SERVICE_ variables first for dockercompose if ($this->build_pack === 'dockercompose') { - $domains = collect(json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []); - - // Generate SERVICE_FQDN & SERVICE_URL for dockercompose - // Env keys always use underscore-normalized names so hyphen/dot storage keys stay valid. - foreach ($domains as $forServiceName => $domain) { - $parsedDomain = composeDomainEntryString($domain); - if (filled($parsedDomain)) { - $parsedDomain = str($parsedDomain)->explode(',')->first(); - $coolifyUrl = Url::fromString($parsedDomain); - $coolifyScheme = $coolifyUrl->getScheme(); - $coolifyFqdn = $coolifyUrl->getHost(); - $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); - $envs->push('SERVICE_URL_'.$serviceEnvKey.'='.$coolifyUrl->__toString()); - $envs->push('SERVICE_FQDN_'.$serviceEnvKey.'='.$coolifyFqdn); - } + foreach ($this->composeServiceUrlVariables($this->application->docker_compose_domains) as $key => $value) { + $envs->push($key.'='.$value); } // Generate SERVICE_NAME for dockercompose services from processed compose @@ -2015,21 +2008,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } else { // Generate SERVICE_ variables first for dockercompose preview if ($this->build_pack === 'dockercompose') { - $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: []); - - // Generate SERVICE_FQDN & SERVICE_URL for dockercompose - foreach ($domains as $forServiceName => $domain) { - $parsedDomain = composeDomainEntryString($domain); - if (filled($parsedDomain)) { - $parsedDomain = str($parsedDomain)->explode(',')->first(); - $coolifyUrl = Url::fromString($parsedDomain); - $coolifyScheme = $coolifyUrl->getScheme(); - $coolifyFqdn = $coolifyUrl->getHost(); - $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); - $envs->push('SERVICE_URL_'.$serviceEnvKey.'='.$coolifyUrl->__toString()); - $envs->push('SERVICE_FQDN_'.$serviceEnvKey.'='.$coolifyFqdn); - } + foreach ($this->composeServiceUrlVariables(data_get($this->preview, 'docker_compose_domains')) as $key => $value) { + $envs->push($key.'='.$value); } // Generate SERVICE_NAME for dockercompose services @@ -2112,6 +2092,38 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ); } + /** + * SERVICE_URL_* and SERVICE_FQDN_* values from the compose domains. Port-suffixed variables + * that the parser stored (SERVICE_URL_APP_3000) get the same value with the port appended. + * + * @return array + */ + private function composeServiceUrlVariables(?string $domainsJson): array + { + $storedKeys = $this->application->environment_variables()->pluck('key'); + $variables = []; + foreach (json_decode($domainsJson ?: '[]', true) ?: [] as $forServiceName => $domain) { + $parsedDomain = composeDomainEntryString($domain); + if (blank($parsedDomain)) { + continue; + } + $url = Url::fromString(str($parsedDomain)->explode(',')->first())->withPort(null); + $values = ['URL' => $url->__toString(), 'FQDN' => $url->getHost()]; + $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper()->value(); + foreach ($values as $type => $value) { + $variables["SERVICE_{$type}_{$serviceEnvKey}"] = $value; + } + $pattern = '/^SERVICE_(URL|FQDN)_'.preg_quote($serviceEnvKey, '/').'_(\d+)$/'; + foreach ($storedKeys as $key) { + if (preg_match($pattern, $key, $matches)) { + $variables[$key] = "{$values[$matches[1]]}:{$matches[2]}"; + } + } + } + + return $variables; + } + private function isGeneratedDockerComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool { $key = str($environmentVariable->key); @@ -2333,19 +2345,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } // Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments - $domains = collect(json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []); - foreach ($domains as $forServiceName => $domain) { - $parsedDomain = composeDomainEntryString($domain); - if (filled($parsedDomain)) { - $parsedDomain = str($parsedDomain)->explode(',')->first(); - $coolifyUrl = Url::fromString($parsedDomain); - $coolifyScheme = $coolifyUrl->getScheme(); - $coolifyFqdn = $coolifyUrl->getHost(); - $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); - $envs_dict['SERVICE_URL_'.$serviceEnvKey] = escapeBashEnvValue($coolifyUrl->__toString()); - $envs_dict['SERVICE_FQDN_'.$serviceEnvKey] = escapeBashEnvValue($coolifyFqdn); - } + foreach ($this->composeServiceUrlVariables($this->application->docker_compose_domains) as $key => $value) { + $envs_dict[$key] = escapeBashEnvValue($value); } } else { // Generate SERVICE_NAME for preview deployments @@ -2356,19 +2357,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } // Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains - $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: []); - foreach ($domains as $forServiceName => $domain) { - $parsedDomain = composeDomainEntryString($domain); - if (filled($parsedDomain)) { - $parsedDomain = str($parsedDomain)->explode(',')->first(); - $coolifyUrl = Url::fromString($parsedDomain); - $coolifyScheme = $coolifyUrl->getScheme(); - $coolifyFqdn = $coolifyUrl->getHost(); - $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); - $envs_dict['SERVICE_URL_'.$serviceEnvKey] = escapeBashEnvValue($coolifyUrl->__toString()); - $envs_dict['SERVICE_FQDN_'.$serviceEnvKey] = escapeBashEnvValue($coolifyFqdn); - } + foreach ($this->composeServiceUrlVariables(data_get($this->preview, 'docker_compose_domains')) as $key => $value) { + $envs_dict[$key] = escapeBashEnvValue($value); } } } @@ -5480,6 +5470,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); private function generate_docker_env_flags_for_secrets() { + $this->helperBuildSecretEnvironmentKeys = []; + // Only generate env flags if build secrets are enabled and the deployment builds an image if (! $this->application->settings->use_build_secrets || ! $this->deploymentPassesBuildtimeVariables()) { return ''; @@ -5503,26 +5495,51 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); return ''; } - // Get database env vars to check for multiline flag - $env_vars = $this->pull_request_id === 0 - ? $this->application->environment_variables()->where('is_buildtime', true)->get() - : $this->application->environment_variables_preview()->where('is_buildtime', true)->get(); - - // Map to simple array format for the helper function - $vars_array = $variables->map(function ($value, $key) use ($env_vars) { - $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build secret environment'); - $env = $env_vars->firstWhere('key', $key); - - return [ - 'key' => $key, - 'value' => $value, - 'is_multiline' => $env ? $env->is_multiline : false, - ]; - }); + $vars_array = $this->withRawBuildSecretValues($variables)->map(fn ($value, $key): array => [ + 'key' => $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build secret environment'), + 'value' => $value, + ]); + $this->helperBuildSecretEnvironmentKeys = $vars_array->keys()->map(fn ($key): string => (string) $key)->values()->all(); return generateDockerEnvFlags($vars_array); } + /** + * The build-time values are escaped or quoted for .env files and shell build arguments, where the + * shell removes the escapes again. A build secret gets its value from the helper container as it + * is, so it gets the raw value of each variable instead. + * + * @param Collection $variables + * @return Collection + */ + private function withRawBuildSecretValues(Collection $variables): Collection + { + $environmentVariables = $this->pull_request_id === 0 + ? $this->application->environment_variables() + : $this->application->environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues()); + $rawValues = $environmentVariables + ->where('is_buildtime', true) + ->whereIn('key', $variables->keys()->all()) + ->get() + ->mapWithKeys(fn (EnvironmentVariable $env): array => [$env->key => $this->resolve_environment_variable_raw($env)]); + + return $variables->map(fn ($value, $key) => $rawValues->get($key, $value)); + } + + /** + * Docker Compose prefers the process environment over `--env-file`. A command in the helper container + * that pulls or starts services must not see the build-time values that the container holds for build + * secrets, so the services get the values of the runtime .env file. + */ + private function withoutHelperBuildSecretEnvironment(string $command): string + { + if ($this->helperBuildSecretEnvironmentKeys === []) { + return $command; + } + + return 'unset '.implode(' ', $this->helperBuildSecretEnvironmentKeys)."; {$command}"; + } + private function generate_build_secrets(Collection $variables) { if ($variables->isEmpty()) { @@ -5540,7 +5557,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ->implode(' '); // Docker ignores secret values for the build cache, so the hash goes in as a build arg - $this->build_secrets .= ' --build-arg '.escapeshellarg('COOLIFY_BUILD_SECRETS_HASH='.$this->generate_secrets_hash($variables)); + $this->build_secrets .= ' --build-arg '.escapeshellarg('COOLIFY_BUILD_SECRETS_HASH='.$this->generate_secrets_hash($this->withRawBuildSecretValues($variables))); } private function generate_secrets_hash($variables) @@ -6071,7 +6088,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); 'environment' => $key, ]; } - $secretsHash = $this->generate_secrets_hash($variables); + $secretsHash = $this->generate_secrets_hash($this->withRawBuildSecretValues($variables)); $services = data_get($composeFile, 'services', []); foreach ($services as $serviceName => &$service) { diff --git a/app/Jobs/CleanupHelperContainersJob.php b/app/Jobs/CleanupHelperContainersJob.php index 368bad5eaf..2c44b57dde 100644 --- a/app/Jobs/CleanupHelperContainersJob.php +++ b/app/Jobs/CleanupHelperContainersJob.php @@ -37,56 +37,42 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S return 'docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image|test("(^|/)coollabsio/coolify-helper(:|@)")))\''; } + /** + * Removes helper containers of finished deployments. Backups, imports, and file browsers also run in + * helper containers, so a container is removed only when its name is the UUID of a deployment that + * is not queued or in progress. + */ public function handle(): void { try { - // Get all active deployments on this server - $activeDeployments = ApplicationDeploymentQueue::where('server_id', $this->server->id) - ->whereIn('status', [ + $containers = instant_remote_process_with_timeout([self::helperContainersCommand()], $this->server, false); + $helperContainers = collect(json_decode((string) $containers, true) ?: []) + ->filter(fn (mixed $container): bool => is_array($container) && filled(data_get($container, 'ID')) && filled(data_get($container, 'Names'))); + + if ($helperContainers->isEmpty()) { + return; + } + + $finishedDeploymentUuids = ApplicationDeploymentQueue::query() + ->whereIn('deployment_uuid', $helperContainers->pluck('Names')->all()) + ->whereNotIn('status', [ ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value, ]) ->pluck('deployment_uuid') - ->toArray(); + ->all(); - \Log::info('CleanupHelperContainersJob - Active deployments', [ - 'server' => $this->server->name, - 'active_deployment_uuids' => $activeDeployments, - ]); - - $containers = instant_remote_process_with_timeout([self::helperContainersCommand()], $this->server, false); - $helperContainers = collect(json_decode($containers)); - - if ($helperContainers->count() > 0) { - foreach ($helperContainers as $container) { - $containerId = data_get($container, 'ID'); - $containerName = data_get($container, 'Names'); - - // Check if this container belongs to an active deployment - $isActiveDeployment = false; - foreach ($activeDeployments as $deploymentUuid) { - if (str_contains($containerName, $deploymentUuid)) { - $isActiveDeployment = true; - break; - } - } - - if ($isActiveDeployment) { - \Log::info('CleanupHelperContainersJob - Skipping active deployment container', [ - 'container' => $containerName, - 'id' => $containerId, - ]); - - continue; - } - - \Log::info('CleanupHelperContainersJob - Removing orphaned helper container', [ - 'container' => $containerName, - 'id' => $containerId, - ]); - - instant_remote_process_with_timeout(['docker container rm -f '.$containerId], $this->server, false); + foreach ($helperContainers as $container) { + if (! in_array($container['Names'], $finishedDeploymentUuids, true)) { + continue; } + + \Log::info('CleanupHelperContainersJob - Removing orphaned deployment helper container', [ + 'container' => $container['Names'], + 'id' => $container['ID'], + ]); + + instant_remote_process_with_timeout(['docker container rm -f '.escapeshellarg($container['ID'])], $this->server, false); } } catch (\Throwable $e) { send_internal_notification('CleanupHelperContainersJob failed with error: '.$e->getMessage()); diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 2fe350e37d..6ffcda6ed4 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -20,7 +20,6 @@ use App\Models\Team; use App\Notifications\Database\BackupFailed; use App\Notifications\Database\BackupSuccess; use App\Notifications\Database\BackupSuccessWithS3Warning; -use App\Rules\SafeWebhookUrl; use App\Services\ScheduledJobDeliveryService; use App\Services\Security\SensitiveDataRedactor; use App\Support\BackupCompression; @@ -169,6 +168,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue // The container name is validated above, so it is a safe folder name. Keep the folder of earlier releases. $this->directory_name = $serviceName.'-'.$this->container_name; $escapedContainerName = escapeshellarg($this->container_name); + $configuredDatabases = $this->configuredDatabasesToBackup(); if (str($databaseType)->contains('postgres')) { $commands[] = "docker exec {$escapedContainerName} env | grep -E '^(POSTGRES|PGUSER=)'"; $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); @@ -180,7 +180,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue return str($env)->startsWith('POSTGRES_DB='); })->first(); - if ($db) { + if (filled($configuredDatabases)) { + $databasesToBackup = $configuredDatabases; + } elseif ($db) { $databasesToBackup = str($db)->after('POSTGRES_DB=')->value(); } else { $databasesToBackup = $this->database->postgres_user; @@ -207,15 +209,14 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue return str($env)->startsWith('MYSQL_DATABASE='); })->first(); - if ($db) { + if (filled($configuredDatabases)) { + $databasesToBackup = $configuredDatabases; + } elseif ($db) { $databasesToBackup = str($db)->after('MYSQL_DATABASE=')->value(); } else { - $databasesToBackup = $this->configuredDatabasesToBackup(); - if (blank($databasesToBackup)) { - $this->recordUndeterminedDatabases('MYSQL_DATABASE'); + $this->recordUndeterminedDatabases('MYSQL_DATABASE'); - return; - } + return; } } elseif (str($databaseType)->contains('mariadb')) { $commands[] = "docker exec {$escapedContainerName} env"; @@ -235,27 +236,17 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } - $db = $envs->filter(function ($env) { - return str($env)->startsWith('MARIADB_DATABASE='); - })->first(); + $db = $envs->first(fn ($env) => str($env)->startsWith('MARIADB_DATABASE=')) + ?? $envs->first(fn ($env) => str($env)->startsWith('MYSQL_DATABASE=')); - if ($db) { - $databasesToBackup = str($db)->after('MARIADB_DATABASE=')->value(); + if (filled($configuredDatabases)) { + $databasesToBackup = $configuredDatabases; + } elseif ($db) { + $databasesToBackup = str($db)->after('=')->value(); } else { - $db = $envs->filter(function ($env) { - return str($env)->startsWith('MYSQL_DATABASE='); - })->first(); + $this->recordUndeterminedDatabases('MARIADB_DATABASE or MYSQL_DATABASE'); - if ($db) { - $databasesToBackup = str($db)->after('MYSQL_DATABASE=')->value(); - } else { - $databasesToBackup = $this->configuredDatabasesToBackup(); - if (blank($databasesToBackup)) { - $this->recordUndeterminedDatabases('MARIADB_DATABASE or MYSQL_DATABASE'); - - return; - } - } + return; } } elseif (str($databaseType)->contains('mongo')) { $databasesToBackup = ['*']; @@ -942,10 +933,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $escapedBackupLocation = escapeshellarg($this->backup_location); $escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/"); - $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint, $s3->trustedInternalHosts())) - ->map(fn (string $resolveOption): string => '--resolve '.escapeshellarg($resolveOption)) - ->implode(' '); - $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; + $resolveOptions = $s3->minioClientResolveOptions($this->server, $fullImageName, $network); instant_remote_process($commands, $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); $this->setMinioClientAlias($this->server, "backup-of-{$this->backup_log_uuid}", $s3, $resolveOptions); @@ -1104,7 +1092,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } /** - * Databases picked on the backup, for service databases whose container does not name one. + * Databases picked in the backup settings of a service database. They take priority over the + * database that the container env names, as for standalone databases. * * @return string|array|null */ diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index 42f209d1e5..90f75c8c6b 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -13,7 +13,6 @@ use App\Models\Server; use App\Models\VolumeBackupS3Replica; use App\Notifications\VolumeBackup\BackupFailed; use App\Notifications\VolumeBackup\BackupSuccess; -use App\Rules\SafeWebhookUrl; use App\Services\ScheduledJobDeliveryService; use App\Services\Security\SensitiveDataRedactor; use App\Support\BackupCompression; @@ -104,7 +103,10 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $archiveScript = self::archiveScript($compressionCpuPercentage); if ($streamToS3) { $this->execution->update(['local_storage_deleted' => true]); - $archiveCommand = $this->streamToS3Command($destinations->first(), $archiveScript, $backupLocation, $containerName); + $streamS3 = $destinations->first(); + $streamS3->testConnection(shouldSave: true); + $streamResolveOptions = $streamS3->minioClientResolveOptions($server, $image); + $archiveCommand = $this->streamToS3Command($streamS3, $archiveScript, $backupLocation, $containerName, $streamResolveOptions); $this->execution->update(['s3_cleanup_pending' => true]); } else { $archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName) @@ -128,7 +130,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue try { if ($streamToS3) { - $this->startStreamHelper($destinations->first(), $verifySourceCommand, $source, $containerName, $image, $server); + $this->startStreamHelper($streamS3, $verifySourceCommand, $source, $containerName, $image, $server, $streamResolveOptions); } $archiveOutput = instant_remote_process(array_filter([ $verifySourceCommand, @@ -440,7 +442,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $s3->testConnection(shouldSave: true); $containerName = 'volume-upload-'.$this->execution->uuid; $image = coolifyHelperImage().':'.getHelperVersion(); - $resolveOptions = $this->minioClientResolveOptions($s3); + $resolveOptions = $s3->minioClientResolveOptions($server, $image); try { instant_remote_process([ @@ -467,7 +469,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue * Starts the helper container that streams the volume to S3 and sets its mc alias. The archive then * runs in it with `docker exec`, so the S3 keys are never part of a command line. */ - private function startStreamHelper(S3Storage $s3, string $verifySourceCommand, string $source, string $containerName, string $image, Server $server): void + private function startStreamHelper(S3Storage $s3, string $verifySourceCommand, string $source, string $containerName, string $image, Server $server, string $resolveOptions): void { instant_remote_process([ $verifySourceCommand, @@ -477,7 +479,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue .' -v '.escapeshellarg($source.':/volume:ro') .' '.escapeshellarg($image).' >/dev/null', ], $server, timeout: $this->timeout, disableMultiplexing: true); - $this->setMinioClientAlias($server, $containerName, $s3, $this->minioClientResolveOptions($s3)); + $this->setMinioClientAlias($server, $containerName, $s3, $resolveOptions); } /** @@ -506,19 +508,8 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue ); } - private function minioClientResolveOptions(S3Storage $s3): string + private function streamToS3Command(S3Storage $s3, string $archiveScript, string $backupLocation, string $containerName, string $resolveOptions): string { - $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint, $s3->trustedInternalHosts())) - ->map(fn (string $option): string => '--resolve '.escapeshellarg($option)) - ->implode(' '); - - return $resolveOptions === '' ? '' : ' '.$resolveOptions; - } - - private function streamToS3Command(S3Storage $s3, string $archiveScript, string $backupLocation, string $containerName): string - { - $s3->testConnection(shouldSave: true); - $resolveOptions = $this->minioClientResolveOptions($s3); $destination = 'temporary/'.$s3->bucket.$backupLocation; // `mc pipe --quiet` panics in recent mc releases, so keep its progress bar out of the error output instead. // Each progress frame starts with "\r", so printing the last field per line keeps only the final frame and real errors. diff --git a/app/Jobs/VolumeCloneJob.php b/app/Jobs/VolumeCloneJob.php index 953afc4eb1..4887619509 100644 --- a/app/Jobs/VolumeCloneJob.php +++ b/app/Jobs/VolumeCloneJob.php @@ -4,6 +4,8 @@ namespace App\Jobs; use App\Models\LocalPersistentVolume; use App\Models\Server; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use App\Traits\StagesCloneArchives; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -202,11 +204,40 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue foreach ($configuration['options'] as $key => $value) { $command .= ' --opt '.escapeshellarg("{$key}={$value}"); } + foreach ($this->composeVolumeLabels() as $key => $value) { + $command .= ' --label '.escapeshellarg("{$key}={$value}"); + } $command .= ' '.escapeshellarg($this->targetVolume); return 'sh -c '.escapeshellarg($command); } + /** + * The labels that Docker Compose puts on a volume it creates. Without the project label, every + * `docker compose up` of the resource warns that the volume "was not created by Docker Compose". + * The project is the `--project-name` of the resource: the service UUID for a service, else the + * resource UUID (a standalone database uses its configuration directory, which is its UUID). + * The volume key in the generated Compose file is the volume name. + * + * @return array + */ + protected function composeVolumeLabels(): array + { + $resource = $this->persistentVolume->resource; + $project = $resource instanceof ServiceApplication || $resource instanceof ServiceDatabase + ? $resource->service?->uuid + : $resource?->uuid; + + if (blank($project)) { + return []; + } + + return [ + 'com.docker.compose.project' => (string) $project, + 'com.docker.compose.volume' => $this->targetVolume, + ]; + } + /** * A `local` volume with `type: none` and `o: bind` is a host folder. Copying the data would write * into that folder (on the same server, the folder of the source volume itself), so the clone gets diff --git a/app/Livewire/Project/New/GiteaPrivateRepository.php b/app/Livewire/Project/New/GiteaPrivateRepository.php index 1fda4b380b..eddde3d039 100644 --- a/app/Livewire/Project/New/GiteaPrivateRepository.php +++ b/app/Livewire/Project/New/GiteaPrivateRepository.php @@ -167,6 +167,7 @@ class GiteaPrivateRepository extends Component { try { $this->authorize('create', Application::class); + $this->base_directory = ValidationPatterns::normalizeDirectoryPath($this->base_directory); $validator = validator([ 'selected_repository_path' => $this->selected_repository_path, diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 3aa195643f..6fea06c682 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -202,6 +202,8 @@ class GithubPrivateRepository extends Component public function submit() { try { + $this->base_directory = ValidationPatterns::normalizeDirectoryPath($this->base_directory); + // Validate git repository parts and branch $validator = validator([ 'selected_repository_owner' => $this->selected_repository_owner, diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index abceaba1b8..e14a92fe97 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -159,6 +159,7 @@ class GithubPrivateRepositoryDeployKey extends Component { $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); + $this->base_directory = ValidationPatterns::normalizeDirectoryPath($this->base_directory); $this->validate(); try { $destination_uuid = $this->query['destination'] ?? null; diff --git a/app/Livewire/Project/New/GitlabPrivateRepository.php b/app/Livewire/Project/New/GitlabPrivateRepository.php index 2828700726..6a7590250a 100644 --- a/app/Livewire/Project/New/GitlabPrivateRepository.php +++ b/app/Livewire/Project/New/GitlabPrivateRepository.php @@ -163,6 +163,8 @@ class GitlabPrivateRepository extends Component public function submit() { try { + $this->base_directory = ValidationPatterns::normalizeDirectoryPath($this->base_directory); + $validator = validator([ 'selected_repository_path' => $this->selected_repository_path, 'selected_branch_name' => $this->selected_branch_name, diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index daf713a679..601130cbd6 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -277,6 +277,8 @@ class PublicGitRepository extends Component public function submit() { try { + $this->base_directory = ValidationPatterns::normalizeDirectoryPath($this->base_directory); + $this->validate(); // Additional validation for git repository and branch diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 9378bac8eb..39a6d81243 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -307,7 +307,11 @@ class EnvironmentVariable extends BaseModel $value = $this->get_real_environment_variables_internal($this->value, $resource, null, $revealLockedSharedVariables, $sharedVariables, $canReveal); - return $value === null || ! $escapeLegacyValue || ! $this->uses_legacy_escaping ? $value : $this->legacyEscapedValue($value); + if ($value === null || ! $escapeLegacyValue || ! $this->uses_legacy_escaping) { + return $value; + } + + return method_exists($resource, 'useExactEscaping') && $resource->useExactEscaping($this) ? $value : $this->legacyEscapedValue($value); } /** diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 84bf072dc1..eabe8f322c 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -44,6 +44,11 @@ class S3Storage extends BaseModel */ private const REMOTE_COMMAND_TIMEOUT_EXIT_CODE = 124; + /** + * How long the helper container waits for a TCP connection to one IP of the endpoint. + */ + private const RESOLVE_PROBE_TIMEOUT_SECONDS = 5; + protected $fillable = [ 'team_id', 'name', @@ -369,6 +374,43 @@ class S3Storage extends BaseModel return ' --api S3v4'.($this->usesPathStyleEndpoint() ? '' : ' --path off'); } + /** + * The mc `--resolve` option that pins the endpoint host to one of its validated IPs. mc keeps one IP per host, + * so when the host has more than one IP, a helper container on the upload network connects to each IP, IPv4 + * first, and the first IP that connects is used. A server without IPv4 then uses an IPv6 address of a + * dual-stack endpoint. When no IP connects, the first IP is used, so mc reports the real connection error. + */ + public function minioClientResolveOptions(Server $server, string $image, ?string $network = null): string + { + $entries = SafeWebhookUrl::minioClientResolveOptions($this->endpoint, $this->trustedInternalHosts()); + if ($entries === []) { + return ''; + } + + $entry = $entries[0]; + if (count($entries) > 1) { + $ips = array_map(fn (string $entry): string => str($entry)->afterLast('=')->toString(), $entries); + $port = (int) str($entries[0])->beforeLast('=')->afterLast(':')->toString(); + $probe = 'for ip in '.implode(' ', array_map('escapeshellarg', $ips)).'; do' + .' if timeout '.self::RESOLVE_PROBE_TIMEOUT_SECONDS.' bash -c "exec 3<>/dev/tcp/$ip/'.$port.'" 2>/dev/null; then echo "$ip"; exit 0; fi;' + .' done'; + $reachableIp = trim((string) instant_remote_process( + ['docker run --rm'.($network === null ? '' : ' --network '.escapeshellarg($network)) + .' '.escapeshellarg($image).' bash -c '.escapeshellarg($probe)], + $server, + throwError: false, + timeout: count($ips) * self::RESOLVE_PROBE_TIMEOUT_SECONDS + 60, + disableMultiplexing: true, + )); + $reachableIndex = array_search($reachableIp, $ips, true); + if ($reachableIndex !== false) { + $entry = $entries[$reachableIndex]; + } + } + + return ' --resolve '.escapeshellarg($entry); + } + /** * Runs an upload of a local file with the MinIO client again when it fails, for example after a short network * outage. A timeout is not retried, because another full attempt can pass the timeout of the backup job. diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php index c872dde82b..23010dc6d5 100644 --- a/app/Rules/SafeWebhookUrl.php +++ b/app/Rules/SafeWebhookUrl.php @@ -194,7 +194,7 @@ class SafeWebhookUrl implements ValidationRule } /** - * Build mc --resolve mappings that pin the endpoint host for S3 backups. + * Build mc --resolve mappings, IPv4 first, that pin the endpoint host for S3 backups. * * @return array */ @@ -206,17 +206,17 @@ class SafeWebhookUrl implements ValidationRule return []; } - // The MinIO client keeps one --resolve IP per host and parses it with Go's - // netip.ParseAddr, which rejects bracketed IPv6. Emit a single entry, preferring - // IPv4 for reachability, without brackets. - $preferredIps = array_values(array_filter( - $target['ips'], - fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false, - )); + // mc parses --resolve values with Go's netip.ParseAddr, which rejects bracketed IPv6. + // mc also keeps one IP per host, so S3Storage::minioClientResolveOptions() picks one entry. + $ips = $target['ips']; + usort($ips, fn (string $a, string $b): int => self::isIpv6($a) <=> self::isIpv6($b)); - $ip = $preferredIps[0] ?? $target['ips'][0]; + return array_map(fn (string $ip): string => sprintf('%s:%d=%s', $target['host'], $target['port'], $ip), $ips); + } - return [sprintf('%s:%d=%s', $target['host'], $target['port'], $ip)]; + private static function isIpv6(string $ip): bool + { + return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; } public static function redactedUrlForLog(string $url): string diff --git a/app/Services/SentinelMetricsClient.php b/app/Services/SentinelMetricsClient.php index 195adc7a26..45bb91aeaf 100644 --- a/app/Services/SentinelMetricsClient.php +++ b/app/Services/SentinelMetricsClient.php @@ -85,6 +85,64 @@ class SentinelMetricsClient ]; } + /** + * Optional charts must not occupy web workers for the general SSH timeout. + * One in-flight read per server, a short shared response cache, and a cooldown + * keep repeated dashboard refreshes from reconnecting to an unavailable server. + */ + public static function fetchOverSsh(Server $server, array $commands, string $cacheKey): ?string + { + $prefix = 'sentinel:metrics-ssh:'.$server->id; + $responseKey = $prefix.':response:'.hash('sha256', $cacheKey); + if (Cache::has($prefix.':unavailable')) { + return null; + } + $cached = Cache::get($responseKey); + if (is_string($cached)) { + return $cached; + } + + $lock = Cache::lock($prefix.':inflight', 15); + if (! $lock->get()) { + return null; + } + + try { + // Another node may have completed a fetch while this lock was acquired. + if (Cache::has($prefix.':unavailable')) { + return null; + } + $cached = Cache::get($responseKey); + if (is_string($cached)) { + return $cached; + } + + try { + $response = instant_remote_process( + $commands, + $server, + timeout: 8, + // Mux setup and health checks have separate, longer timeouts. + disableMultiplexing: true, + maxAttempts: 1 + ); + if ($response === null || trim($response) === '') { + throw new \RuntimeException('Metrics returned an empty response.'); + } + } catch (\Throwable) { + Cache::put($prefix.':unavailable', true, 60); + + return null; + } + + Cache::put($responseKey, $response, 10); + + return $response; + } finally { + $lock->release(); + } + } + /** Rounded to the minute so the history URL (and its cache key) is stable within a minute. */ public static function rangeFrom(string $range): string { @@ -331,14 +389,13 @@ class SentinelMetricsClient { $token = $this->server->settings->ensureValidSentinelToken(); - return instant_remote_process( - ["docker exec coolify-sentinel sh -c 'curl -s --max-time 10 -H \"Authorization: Bearer {$token}\" \"{$url}\"'"], + $command = "curl -sS --connect-timeout 2 --max-time 3 -H @- \"{$url}\" <<'COOLIFY_SENTINEL_AUTH'\nAuthorization: Bearer {$token}\nCOOLIFY_SENTINEL_AUTH"; + + return self::fetchOverSsh( $this->server, - false, - timeout: 15, - // Metrics are read again on the next refresh. SSH retries would keep a worker past its timeout. - maxAttempts: 1 - ); + ["docker exec -i coolify-sentinel sh -s <<'COOLIFY_SENTINEL_SCRIPT'\n{$command}\nCOOLIFY_SENTINEL_SCRIPT"], + $url + ) ?? ''; } /** diff --git a/app/Services/ServerTransfer/TransferFinishChecklist.php b/app/Services/ServerTransfer/TransferFinishChecklist.php index d894b49cb3..25d6d19e61 100644 --- a/app/Services/ServerTransfer/TransferFinishChecklist.php +++ b/app/Services/ServerTransfer/TransferFinishChecklist.php @@ -407,14 +407,29 @@ class TransferFinishChecklist : ['action', 'This instance does not own the record yet.', 'Until it owns the record, this instance does not change or delete it.']; } + /** + * The IPv4 and IPv6 addresses of a host name, in the normalized form of normalizeIpAddress(). + * + * @return array + */ + private function resolveIps(string $host): array + { + $records = @dns_get_record($host, DNS_A | DNS_AAAA) ?: []; + + return array_values(array_filter(array_map( + fn (array $record): ?string => isset($record['ip']) || isset($record['ipv6']) ? normalizeIpAddress($record['ip'] ?? $record['ipv6']) : null, + $records, + ))); + } + /** * @return array{0: string, 1: string, 2: string|null} */ private function checkDomain(Server $server, string $host): array { - $serverIps = filter_var($server->ip, FILTER_VALIDATE_IP) ? [$server->ip] : (gethostbynamel($server->ip) ?: []); - $records = @dns_get_record($host, DNS_A | DNS_AAAA) ?: []; - $ips = array_values(array_filter(array_map(fn (array $record) => $record['ip'] ?? $record['ipv6'] ?? null, $records))); + $serverIp = normalizeIpAddress($server->ip); + $serverIps = filter_var($serverIp, FILTER_VALIDATE_IP) ? [$serverIp] : $this->resolveIps($serverIp); + $ips = $this->resolveIps($host); if ($ips === []) { return ['action', 'The domain does not resolve.', null]; } diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 77c89f2fbe..1aa46ddabd 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -576,6 +576,17 @@ class ValidationPatterns return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DIRECTORY_PATH_PATTERN]; } + /** + * Normalize a typed directory path to the form that DIRECTORY_PATH_PATTERN expects: + * "apps/web/" becomes "/apps/web", and an empty value becomes "/". + */ + public static function normalizeDirectoryPath(?string $path): string + { + $path = trim(trim((string) $path), '/'); + + return '/'.$path; + } + /** * Get validation rules for Docker build target fields */ diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php index 3a1648fc3f..1f74df8618 100644 --- a/app/Traits/HasMetrics.php +++ b/app/Traits/HasMetrics.php @@ -12,14 +12,14 @@ trait HasMetrics /** * Raw Sentinel responses from prefetchMetrics(), keyed by "type:mins"; each is read once. * - * @var array + * @var array */ protected array $prefetchedMetricResponses = []; /** * Fetch several metric types in one SSH round-trip instead of one per type. The - * get*Metrics() getters then read these responses. On a failure nothing is stored, - * so each getter fetches on its own as before. + * get*Metrics() getters then read these responses. Failed prefetches are remembered + * for this request so the getters do not retry the same unavailable server. * * @param array $types */ @@ -30,23 +30,26 @@ trait HasMetrics return; } + foreach ($types as $type) { + $this->prefetchedMetricResponses["{$type}:{$mins}"] = null; + } + $from = now()->subMinutes($mins)->toIso8601ZuluString(); $token = $this->metricsToken($server); // The token reaches curl on stdin (`-H @-`), so it is not a process argument. The token // only has letters, digits and `._-+/=` (ServerSetting::isValidSentinelToken), so it // cannot end the heredoc. Responses are separated by a 0x1E record separator. $script = implode("\nprintf '\\036'\n", array_map( - fn (string $type) => 'curl -s -H @- "'.$this->getMetricsEndpoint($type, $from, $mins)."\" <<'COOLIFY_SENTINEL_AUTH'\nAuthorization: Bearer {$token}\nCOOLIFY_SENTINEL_AUTH", + fn (string $type) => 'curl -sS --connect-timeout 2 --max-time 3 -H @- "'.$this->getMetricsEndpoint($type, $from, $mins)."\" <<'COOLIFY_SENTINEL_AUTH'\nAuthorization: Bearer {$token}\nCOOLIFY_SENTINEL_AUTH", $types )); - try { - $output = instant_remote_process( - ["docker exec -i coolify-sentinel sh -s <<'COOLIFY_SENTINEL_SCRIPT'\n{$script}\nCOOLIFY_SENTINEL_SCRIPT"], - $server, - maxAttempts: 1 - ); - } catch (\Throwable) { + $output = SentinelMetricsClient::fetchOverSsh( + $server, + ["docker exec -i coolify-sentinel sh -s <<'COOLIFY_SENTINEL_SCRIPT'\n{$script}\nCOOLIFY_SENTINEL_SCRIPT"], + static::class.':'.$this->uuid.':'.implode(',', $types).':'.$mins + ); + if ($output === null) { return; } @@ -152,13 +155,18 @@ trait HasMetrics $endpoint = $this->getMetricsEndpoint($type, $from, $mins); $token = $this->metricsToken($server); - $response = instant_remote_process( - ["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$token}\" \"{$endpoint}\"'"], + $command = "curl -sS --connect-timeout 2 --max-time 3 -H @- \"{$endpoint}\" <<'COOLIFY_SENTINEL_AUTH'\nAuthorization: Bearer {$token}\nCOOLIFY_SENTINEL_AUTH"; + $response = SentinelMetricsClient::fetchOverSsh( $server, - false + ["docker exec -i coolify-sentinel sh -s <<'COOLIFY_SENTINEL_SCRIPT'\n{$command}\nCOOLIFY_SENTINEL_SCRIPT"], + static::class.':'.$this->uuid.':'.$type.':'.$mins ); } + if ($response === null) { + return null; + } + if (str($response)->contains('error')) { $error = json_decode($response, true); $error = data_get($error, 'error', 'Something is not okay, are you okay?'); diff --git a/app/Traits/HasSecretManager.php b/app/Traits/HasSecretManager.php index dd9195c9af..621a29ca01 100644 --- a/app/Traits/HasSecretManager.php +++ b/app/Traits/HasSecretManager.php @@ -146,11 +146,46 @@ trait HasSecretManager * Decides the escaping of a variable for all paths (service .env, database environment, * Redis password). A variable saved before exact escaping keeps the old escaping, so its * container gets the same value as before the upgrade. Saving the variable again switches - * it to exact escaping. + * it to exact escaping. Compose references that got the stored value before the upgrade + * (such as `FOO=${FOO:-}`) use exact escaping, so they keep getting that value. So do old + * .env lines that Docker Compose cannot read, because they stop the deployment. */ public function useExactEscaping(EnvironmentVariable $environmentVariable): bool { - return ! $environmentVariable->uses_legacy_escaping; + if (! $environmentVariable->uses_legacy_escaping) { + return true; + } + + if (! $this instanceof Service && ! $this instanceof Application) { + return false; + } + + if ($this->legacyEnvFileLineIsUnreadable($environmentVariable)) { + return true; + } + + $parsesCompose = $this instanceof Service + || ($this->build_pack === 'dockercompose' && ! $this->settings?->is_raw_compose_deployment_enabled); + + return $parsesCompose + && dockerComposeVariablesWithStoredValueBeforeExactEscaping($this->docker_compose_raw)->contains($environmentVariable->key); + } + + /** + * The old escaping puts literal and multiline values in single quotes without escaping. Docker + * Compose ends the value at the next quote, so a value with a quote breaks the whole .env file. + */ + private function legacyEnvFileLineIsUnreadable(EnvironmentVariable $environmentVariable): bool + { + if (! $environmentVariable->is_literal && ! $environmentVariable->is_multiline) { + return false; + } + + $value = $this->resolvedEnvironmentVariableValue($environmentVariable); + + return is_string($value) + && str_contains($value, "'") + && ! (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))); } /** @@ -210,8 +245,9 @@ trait HasSecretManager } if ($environmentVariable->is_literal || $environmentVariable->is_multiline) { - // Service and application .env files strip the old single quotes; database environment lists keep them. - return ($this instanceof Service || $this instanceof Application) ? str_contains($value, "'") : true; + // Service and application .env files strip the old single quotes (values with a quote use exact + // escaping); database environment lists keep them. + return ! ($this instanceof Service || $this instanceof Application); } return escapeEnvVariables($value) !== $value diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 4bdf9ca2d6..62be1532ad 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -2124,7 +2124,7 @@ function generateDockerBuildArgs($variables): Collection /** * Generate Docker environment flags from environment variables collection * - * @param Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline' + * @param Collection|array $variables Collection of variables with 'key' and 'value' * @return string Space-separated environment flags */ function generateDockerEnvFlags($variables): string @@ -2140,11 +2140,6 @@ function generateDockerEnvFlags($variables): string throw new InvalidArgumentException('Invalid environment variable key.'); } - $isMultiline = is_array($var) ? data_get($var, 'is_multiline', false) : ($var->is_multiline ?? false); - if ($isMultiline) { - $value = trim($value, "'"); - } - return '-e '.escapeshellarg("{$key}={$value}"); }) ->implode(' '); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 7aa5ef279e..b723993b67 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -2422,6 +2422,7 @@ function serviceParser(Service $resource): Collection ->orWhere('key', 'LIKE', '%}'); }) ->delete(); + $existingEnvironmentVariableKeys = $resource->environment_variables()->pluck('key'); $topLevel = collect([ 'volumes' => collect(data_get($yaml, 'volumes', [])), @@ -3671,6 +3672,7 @@ function serviceParser(Service $resource): Collection data_forget($resource, 'environment_variables_preview'); $resource->save(); + fillDockerComposeDefaultsForBareReferences($resource, $existingEnvironmentVariableKeys); syncDockerComposeEnvironmentVariableRequiredState($resource); return $topLevel; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 4d83e03a31..2dbc3467da 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -5580,6 +5580,63 @@ function dockerComposeEnvironmentVariableInfo(?string $dockerCompose, string $ke ]; } +/** + * Docker Compose gives a bare $VAR or ${VAR} reference no default, and the service parser does not store + * defaults. So `/$POSTGRES_DB` next to `${POSTGRES_DB:-umami}` in another service resolves to an empty value. + * For new variables, store the default when the Compose file has a bare reference and exactly one default. + * + * @param Collection $existingKeys + */ +function fillDockerComposeDefaultsForBareReferences(Service $resource, Collection $existingKeys): void +{ + $dockerCompose = $resource->docker_compose_raw; + if (blank($dockerCompose)) { + return; + } + + $resource->environment_variables() + ->whereNull('value') + ->whereNotIn('key', $existingKeys) + ->get() + ->each(function (EnvironmentVariable $environmentVariable) use ($dockerCompose): void { + $default = dockerComposeBareReferenceDefault($dockerCompose, $environmentVariable->key); + // A nested default (`${A:-${B}}`) must stay with Compose: stored, it would be escaped to a literal. + if (filled($default) && ! str_contains($default, '$')) { + $environmentVariable->update(['value' => $default]); + } + }); +} + +/** The single default of a variable that the Compose file also references without a default, otherwise null. */ +function dockerComposeBareReferenceDefault(string $dockerCompose, string $key): ?string +{ + $quotedKey = preg_quote($key, '/'); + if (preg_match('/(? 0 && $dockerCompose[$referenceStart - 1] === '$') { + continue; + } + + $reference = extractBalancedBraceContent(substr($dockerCompose, $referenceStart)); + $split = $reference === null ? null : splitOnOperatorOutsideNested($reference['content']); + if ($split === null || $split['variable'] !== $key) { + continue; + } + if (str_contains($split['operator'], '?')) { + return null; + } + $defaults[$split['default']] = true; + } + + return count($defaults) === 1 ? (string) array_key_first($defaults) : null; +} + function dockerComposeEnvironmentVariableIsRequired(?string $dockerCompose, string $key): bool { if (blank($dockerCompose)) { @@ -5605,6 +5662,54 @@ function dockerComposeEnvironmentVariableShouldBeUnset(?string $dockerCompose, s return extractDockerComposeEnvironmentVariableReferences($dockerCompose)->contains($key); } +/** + * Variables whose stored value the Compose parser before v4.5.0 wrote straight into the `environment:` + * list, so their containers got the value without the old .env escaping: references with a default or + * under another name (`FOO=${FOO:-}`, `BAR=${FOO}`, `BAR=$FOO`) and references in such a default. + * A plain `FOO=${FOO}` reference kept reading the escaped .env value. + * + * @return Collection + */ +function dockerComposeVariablesWithStoredValueBeforeExactEscaping(?string $dockerCompose): Collection +{ + static $cache = []; + if (blank($dockerCompose)) { + return collect(); + } + + $cacheKey = hash('xxh128', $dockerCompose); + if (isset($cache[$cacheKey])) { + return $cache[$cacheKey]; + } + + $keys = collect(); + foreach (extractHardcodedEnvironmentVariables($dockerCompose) as $assignment) { + $value = $assignment['value'] ?? null; + if (! is_string($value) || ! str_starts_with($value, '$') || replaceVariables($value)->value() === (string) $assignment['key']) { + continue; + } + + $reference = extractBalancedBraceContent($value, 0); + if ($reference === null) { + if (preg_match('/\A\$([A-Za-z_][A-Za-z0-9_]*)\z/', $value, $match) === 1) { + $keys->push($match[1]); + } + + continue; + } + + $split = splitOnOperatorOutsideNested($reference['content']); + $keys->push($split === null ? $reference['content'] : $split['variable']); + if ($split !== null) { + $keys = $keys->concat(extractDockerComposeEnvironmentVariableReferences($split['default'])); + } + } + + $cache = [$cacheKey => $keys->reject(fn (string $key): bool => str_starts_with($key, 'SERVICE_'))->unique()->values()]; + + return $cache[$cacheKey]; +} + /** @param Collection $environmentVariables */ function preserveLegacyDockerComposeNullAssignments(Collection $environment, Collection $environmentVariables): Collection { diff --git a/openapi.json b/openapi.json index 11f73af9eb..76ac085a4f 100644 --- a/openapi.json +++ b/openapi.json @@ -14116,6 +14116,9 @@ }, "422": { "$ref": "#\/components\/responses\/422" + }, + "502": { + "description": "Coolify could not connect to the provider API." } }, "security": [ @@ -14309,6 +14312,9 @@ }, "422": { "$ref": "#\/components\/responses\/422" + }, + "502": { + "description": "Coolify could not connect to the provider API." } }, "security": [ @@ -14367,6 +14373,9 @@ }, "422": { "description": "The token is not a Cloudflare token." + }, + "502": { + "description": "Coolify could not connect to the Cloudflare API." } }, "security": [ @@ -23621,37 +23630,7 @@ "content": { "application\/json": { "schema": { - "required": [ - "key" - ], - "properties": { - "key": { - "type": "string" - }, - "value": { - "type": [ - "string", - "null" - ] - }, - "is_literal": { - "type": "boolean" - }, - "is_multiline": { - "type": "boolean" - }, - "is_shown_once": { - "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.", - "type": "boolean" - }, - "comment": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" + "$ref": "#\/components\/schemas\/SharedEnvironmentVariableCreateRequest" } } } @@ -23731,6 +23710,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/SharedEnvironmentVariableUpdateRequest" + } + } + } + }, "responses": { "200": { "description": "Environment variable updated." @@ -23806,6 +23795,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/SharedEnvironmentVariableCreateRequest" + } + } + } + }, "responses": { "201": { "description": "Environment variable created." @@ -23902,6 +23901,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/SharedEnvironmentVariableUpdateRequest" + } + } + } + }, "responses": { "200": { "description": "Environment variable updated." @@ -23995,6 +24004,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/SharedEnvironmentVariableCreateRequest" + } + } + } + }, "responses": { "201": { "description": "Environment variable created." @@ -24109,6 +24128,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/SharedEnvironmentVariableUpdateRequest" + } + } + } + }, "responses": { "200": { "description": "Environment variable updated." @@ -24184,6 +24213,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/SharedEnvironmentVariableCreateRequest" + } + } + } + }, "responses": { "201": { "description": "Environment variable created." @@ -24280,6 +24319,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/SharedEnvironmentVariableUpdateRequest" + } + } + } + }, "responses": { "200": { "description": "Environment variable updated." @@ -26392,6 +26441,72 @@ }, "type": "object" }, + "SharedEnvironmentVariableCreateRequest": { + "required": [ + "key" + ], + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": [ + "string", + "null" + ] + }, + "is_literal": { + "type": "boolean" + }, + "is_multiline": { + "type": "boolean" + }, + "is_shown_once": { + "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.", + "type": "boolean" + }, + "comment": { + "type": [ + "string", + "null" + ], + "maxLength": 256 + } + }, + "type": "object" + }, + "SharedEnvironmentVariableUpdateRequest": { + "description": "Send at least one field.", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": [ + "string", + "null" + ] + }, + "is_literal": { + "type": "boolean" + }, + "is_multiline": { + "type": "boolean" + }, + "is_shown_once": { + "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.", + "type": "boolean" + }, + "comment": { + "type": [ + "string", + "null" + ], + "maxLength": 256 + } + }, + "type": "object" + }, "VolumeBackupScheduleRequest": { "required": [ "frequency" diff --git a/openapi.yaml b/openapi.yaml index 05ef6d55cf..6ba839ae90 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -9042,6 +9042,8 @@ paths: description: 'Not an admin or owner of the team.' '422': $ref: '#/components/responses/422' + '502': + description: 'Coolify could not connect to the provider API.' security: - bearerAuth: [] @@ -9165,6 +9167,8 @@ paths: $ref: '#/components/responses/404' '422': $ref: '#/components/responses/422' + '502': + description: 'Coolify could not connect to the provider API.' security: - bearerAuth: [] @@ -9202,6 +9206,8 @@ paths: $ref: '#/components/responses/404' '422': description: 'The token is not a Cloudflare token.' + '502': + description: 'Coolify could not connect to the Cloudflare API.' security: - bearerAuth: [] @@ -14889,23 +14895,7 @@ paths: content: application/json: schema: - required: - - key - properties: - key: - type: string - value: - type: [string, 'null'] - is_literal: - type: boolean - is_multiline: - type: boolean - is_shown_once: - description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' - type: boolean - comment: - type: [string, 'null'] - type: object + $ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest' responses: '201': description: 'Environment variable created.' @@ -14957,6 +14947,12 @@ paths: required: true schema: type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest' responses: '200': description: 'Environment variable updated.' @@ -15008,6 +15004,12 @@ paths: required: true schema: type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest' responses: '201': description: 'Environment variable created.' @@ -15075,6 +15077,12 @@ paths: required: true schema: type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest' responses: '200': description: 'Environment variable updated.' @@ -15140,6 +15148,12 @@ paths: required: true schema: type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest' responses: '201': description: 'Environment variable created.' @@ -15221,6 +15235,12 @@ paths: required: true schema: type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest' responses: '200': description: 'Environment variable updated.' @@ -15272,6 +15292,12 @@ paths: required: true schema: type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest' responses: '201': description: 'Environment variable created.' @@ -15339,6 +15365,12 @@ paths: required: true schema: type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest' responses: '200': description: 'Environment variable updated.' @@ -16759,6 +16791,51 @@ components: type: string type: object type: object + SharedEnvironmentVariableCreateRequest: + required: + - key + properties: + key: + type: string + value: + type: + - string + - 'null' + is_literal: + type: boolean + is_multiline: + type: boolean + is_shown_once: + description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' + type: boolean + comment: + type: + - string + - 'null' + maxLength: 256 + type: object + SharedEnvironmentVariableUpdateRequest: + description: 'Send at least one field.' + properties: + key: + type: string + value: + type: + - string + - 'null' + is_literal: + type: boolean + is_multiline: + type: boolean + is_shown_once: + description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' + type: boolean + comment: + type: + - string + - 'null' + maxLength: 256 + type: object VolumeBackupScheduleRequest: required: - frequency diff --git a/resources/js/deploy-canvas.js b/resources/js/deploy-canvas.js index 82d28ccd52..318e09f6a7 100644 --- a/resources/js/deploy-canvas.js +++ b/resources/js/deploy-canvas.js @@ -104,7 +104,7 @@ const POSTGRES_IMAGES = [ ['pgvector/pgvector:pg18', 'PGVector 18'], ['pgvector/pgvector:pg17', 'PGVector 17'], ]; -const DATABASES = [['postgresql', 'PostgreSQL'], ['mysql', 'MySQL'], ['mariadb', 'MariaDB'], ['mongodb', 'MongoDB'], ['redis', 'Redis'], ['keydb', 'KeyDB'], ['dragonfly', 'Dragonfly'], ['clickhouse', 'ClickHouse']]; +const DATABASES = [['postgresql', 'PostgreSQL'], ['mysql', 'MySQL'], ['mariadb', 'MariaDB'], ['mongodb', 'MongoDB'], ['redis', 'Redis'], ['keydb', 'KeyDB'], ['dragonfly', 'Dragonfly'], ['clickhouse', 'ClickHouse'], ['sqlite', 'SQLite']]; // One-click service templates ([key, name]), from the page (deploy-canvas.blade.php). let serviceTemplates = null; const templateOptions = () => { diff --git a/resources/js/deploy-canvas.test.js b/resources/js/deploy-canvas.test.js index a8fbecc2d7..1673ce4e33 100644 --- a/resources/js/deploy-canvas.test.js +++ b/resources/js/deploy-canvas.test.js @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { freeSpot, zoomShortcut } from './canvas-core.js'; -import { canFollow, domainList, fresh, logoOf, onlyOption, parseEnvVars, planOf, splitImage, targetFrom, uniqueLabels, withDeploy, withoutSecrets, withTarget } from './deploy-canvas.js'; +import { TYPES, canFollow, domainList, fresh, logoOf, onlyOption, parseEnvVars, planOf, splitImage, targetFrom, uniqueLabels, withDeploy, withoutSecrets, withTarget } from './deploy-canvas.js'; test('splits the tag out of an image name', () => { assert.deepEqual(splitImage('nginx:alpine', 'latest'), { name: 'nginx', tag: 'alpine' }); @@ -167,6 +167,7 @@ test('shows the logo of what a source step creates, like the rest of Coolify', ( assert.equal(src('database', { engine: 'postgresql' }), '/svgs/resources/postgres.svg'); assert.equal(src('database', { engine: 'mysql' }), '/svgs/resources/mysql.svg'); assert.equal(logoOf(node('n', 'database', { engine: 'keydb' })).dark, '/svgs/resources/keydb-dark.svg'); + assert.equal(src('database', { engine: 'sqlite' }), '/svgs/resources/sqlite.svg'); assert.equal(src('git', { mode: 'public', buildPack: 'railpack' }), '/svgs/resources/public-repo.svg'); assert.equal(src('git', { mode: 'github', buildPack: 'railpack' }), '/svgs/resources/github-app.svg'); assert.equal(src('git', { mode: 'key', buildPack: 'railpack' }), '/svgs/resources/deploy-key.svg'); @@ -187,3 +188,8 @@ test('reads the browser zoom shortcut so the canvas can zoom instead', () => { assert.equal(key('-', { ctrlKey: true, altKey: true }), null); assert.equal(key('s', { metaKey: true }), null); }); + +test('offers every database engine of the New resource page', () => { + const engines = TYPES.database.fields.find(f => f.key === 'engine').options.map(([value]) => value); + assert.deepEqual(engines, ['postgresql', 'mysql', 'mariadb', 'mongodb', 'redis', 'keydb', 'dragonfly', 'clickhouse', 'sqlite']); +}); diff --git a/resources/views/livewire/project/database/backup-edit/general.blade.php b/resources/views/livewire/project/database/backup-edit/general.blade.php index 56cf82e507..f5a0c1d738 100644 --- a/resources/views/livewire/project/database/backup-edit/general.blade.php +++ b/resources/views/livewire/project/database/backup-edit/general.blade.php @@ -24,9 +24,14 @@
+ @php + $isServiceSqlDatabase = $backup->database instanceof \App\Models\ServiceDatabase + && str($backup->database->databaseType())->contains(['postgres', 'mysql', 'mariadb']); + @endphp @if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0 || $backup->database_type === 'App\Models\StandaloneMysql' - || $backup->database_type === 'App\Models\StandaloneMariadb') + || $backup->database_type === 'App\Models\StandaloneMariadb' + || $isServiceSqlDatabase)

- Add one or more database names. Leave empty to include the default database. + @if ($isServiceSqlDatabase) + Add one or more database names. Leave empty to back up the database that the container env names. + @else + Add one or more database names. Leave empty to include the default database. + @endif

@endif diff --git a/resources/views/livewire/project/database/scheduled-backups.blade.php b/resources/views/livewire/project/database/scheduled-backups.blade.php index 67c8f441df..024ee0b4e9 100644 --- a/resources/views/livewire/project/database/scheduled-backups.blade.php +++ b/resources/views/livewire/project/database/scheduled-backups.blade.php @@ -86,11 +86,14 @@ || @js(strtolower($backup->frequency)).includes(search.toLowerCase()) || @js(strtolower($s3StorageNames)).includes(search.toLowerCase())" class="data-table-row scheduled-backups-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.06]"> -
+
{{ $backup->frequency }} + @unless ($backup->enabled) + + @endunless
diff --git a/resources/views/livewire/project/new/gitea-private-repository.blade.php b/resources/views/livewire/project/new/gitea-private-repository.blade.php index 6ed15d6312..da18da3cf7 100644 --- a/resources/views/livewire/project/new/gitea-private-repository.blade.php +++ b/resources/views/livewire/project/new/gitea-private-repository.blade.php @@ -135,7 +135,7 @@ file="docker_compose_location" fileLabel="Compose file" defaultFile="/docker-compose.yaml" /> @else - @endif
diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index 6ee5dfe990..313cc5c919 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -85,7 +85,7 @@ file="docker_compose_location" fileLabel="Compose file" defaultFile="/docker-compose.yaml" /> @else - @endif diff --git a/resources/views/livewire/project/new/github-private-repository.blade.php b/resources/views/livewire/project/new/github-private-repository.blade.php index c89472dd7a..431a124175 100644 --- a/resources/views/livewire/project/new/github-private-repository.blade.php +++ b/resources/views/livewire/project/new/github-private-repository.blade.php @@ -162,7 +162,7 @@ file="docker_compose_location" fileLabel="Compose file" defaultFile="/docker-compose.yaml" /> @else - @endif
diff --git a/resources/views/livewire/project/new/gitlab-private-repository.blade.php b/resources/views/livewire/project/new/gitlab-private-repository.blade.php index 0a0e7e152d..064c28d5a6 100644 --- a/resources/views/livewire/project/new/gitlab-private-repository.blade.php +++ b/resources/views/livewire/project/new/gitlab-private-repository.blade.php @@ -139,7 +139,7 @@ file="docker_compose_location" fileLabel="Compose file" defaultFile="/docker-compose.yaml" /> @else - @endif diff --git a/resources/views/livewire/project/new/public-git-repository.blade.php b/resources/views/livewire/project/new/public-git-repository.blade.php index 663e267272..39c186636e 100644 --- a/resources/views/livewire/project/new/public-git-repository.blade.php +++ b/resources/views/livewire/project/new/public-git-repository.blade.php @@ -111,7 +111,7 @@ file="docker_compose_location" fileLabel="Compose file" defaultFile="/docker-compose.yaml" /> @else - @endif diff --git a/resources/views/livewire/project/shared/scheduled-task/all.blade.php b/resources/views/livewire/project/shared/scheduled-task/all.blade.php index 74b962edb6..54b0e5b4a2 100644 --- a/resources/views/livewire/project/shared/scheduled-task/all.blade.php +++ b/resources/views/livewire/project/shared/scheduled-task/all.blade.php @@ -52,6 +52,9 @@

{{ $task->name }}

+ @unless ($task->enabled) + + @endunless @if ($task->container) {{ $task->container }} diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index 3d6707505f..ebdecee8da 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -1842,6 +1842,56 @@ it('keeps the old runtime env format for variables saved before exact escaping', ->toContain('EXACT_PLAIN='.escapeComposeEnvFileValue('a\'b"c\\d', allowInterpolation: true)); }); +it('gives legacy compose variables the value that the compose file got before exact escaping', function (bool $rawCompose, string $expectedDefault) { + $compose = <<<'YAML' +services: + app: + image: alpine + environment: + - TRUSTED_HOST_PATTERNS=${TRUSTED_HOST_PATTERNS:-} + - PLAIN_REFERENCE=${PLAIN_REFERENCE} +YAML; + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $compose, + 'docker_compose' => $compose, + ]); + $application->settings()->update(['is_raw_compose_deployment_enabled' => $rawCompose]); + + foreach (['TRUSTED_HOST_PATTERNS', 'PLAIN_REFERENCE'] as $key) { + $variable = createApplicationEnvironmentVariable($application, ['key' => $key, 'value' => '^example\\.com$', 'is_runtime' => true, 'is_buildtime' => false]); + $variable->uses_legacy_escaping = true; + $variable->save(); + } + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server); + + /** @var Collection $runtimeEnvs */ + $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + + expect($runtimeEnvs) + ->toContain('TRUSTED_HOST_PATTERNS='.$expectedDefault) + ->toContain('PLAIN_REFERENCE=^example\\\\.com$'); +})->with([ + 'parsed compose' => [false, '"^example\\\\.com$"'], + 'raw compose deployment' => [true, '^example\\\\.com$'], +]); + +it('writes a readable runtime env line for legacy literal values with a single quote', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + + $variable = createApplicationEnvironmentVariable($application, ['key' => 'LEGACY_QUOTE', 'value' => "it's", 'is_literal' => true, 'is_runtime' => true, 'is_buildtime' => false]); + $variable->uses_legacy_escaping = true; + $variable->save(); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server); + + /** @var Collection $runtimeEnvs */ + $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + + expect($runtimeEnvs)->toContain('LEGACY_QUOTE="it\'s"'); +}); + it('keeps the old runtime env format for preview variables saved before exact escaping', function () { [$application, $server] = makeDeploymentControlVarFixture(); @@ -2143,3 +2193,113 @@ YAML; 'production' => 0, 'preview' => 42, ]); + +it('passes port-suffixed compose service URL variables to production and preview deployments', function (int $pullRequestId) { + $compose = <<<'YAML' +services: + docmost: + build: . + environment: + - SERVICE_URL_DOCMOST_3000 + - APP_URL=$SERVICE_URL_DOCMOST_3000 +YAML; + + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $compose, + 'docker_compose' => $compose, + 'docker_compose_domains' => json_encode(['docmost' => ['domain' => 'https://docs.example.com']]), + ]); + + foreach ([ + 'SERVICE_URL_DOCMOST' => 'https://docs.example.com', + 'SERVICE_FQDN_DOCMOST' => 'docs.example.com', + 'SERVICE_URL_DOCMOST_3000' => 'https://stale.example.com:3000', + 'SERVICE_FQDN_DOCMOST_3000' => 'stale.example.com:3000', + ] as $key => $value) { + createApplicationEnvironmentVariable($application, ['key' => $key, 'value' => $value, 'is_runtime' => true, 'is_buildtime' => true]); + } + + $overrides = ['pull_request_id' => $pullRequestId]; + $expectedHost = 'docs.example.com'; + if ($pullRequestId !== 0) { + $expectedHost = '7.docs.example.com'; + $overrides['preview'] = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => $pullRequestId, + 'pull_request_html_url' => 'https://github.com/example/repo/pull/7', + 'docker_compose_domains' => json_encode(['docmost' => ['domain' => "https://{$expectedHost}"]]), + ]); + } + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, $overrides); + + $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + expect($runtimeEnvs) + ->toContain("SERVICE_URL_DOCMOST=https://{$expectedHost}") + ->toContain("SERVICE_URL_DOCMOST_3000=https://{$expectedHost}:3000") + ->toContain("SERVICE_FQDN_DOCMOST_3000={$expectedHost}:3000") + ->and($runtimeEnvs->implode("\n"))->not->toContain('stale.example.com'); + + $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables')->implode("\n"); + expect($buildtimeEnvs) + ->toContain("SERVICE_URL_DOCMOST_3000='https://{$expectedHost}:3000'") + ->toContain("SERVICE_FQDN_DOCMOST_3000='{$expectedHost}:3000'") + ->not->toContain('stale.example.com'); +})->with([ + 'production' => [0], + 'preview' => [7], +]); + +it('hides the build secret values of the helper container from commands that start Compose services', function () { + [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockercompose']); + $application->settings()->update(['use_build_secrets' => true]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'DATABASE_URL', + 'value' => 'postgresql://user:pass@db:5432/app', + 'is_literal' => true, + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => 'dockercompose']); + $envFlags = invokeDeploymentJobMethod($job, $reflection, 'generate_docker_env_flags_for_secrets'); + expect($envFlags)->toBe("-e 'DATABASE_URL=postgresql://user:pass@db:5432/app'"); + + // The helper container has the variable in its environment, as `docker run -e` gives it. + $command = invokeDeploymentJobMethod($job, $reflection, 'withoutHelperBuildSecretEnvironment', 'echo "[${DATABASE_URL-unset}]"'); + $process = new Process(['bash', '-c', $command], env: ['DATABASE_URL' => "'postgresql://user:pass@db:5432/app'"]); + $process->mustRun(); + + expect(trim($process->getOutput()))->toBe('[unset]'); +}); + +it('leaves commands unchanged when the helper container has no build secret values', function () { + [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockercompose']); + createApplicationEnvironmentVariable($application, ['key' => 'DATABASE_URL', 'value' => 'value']); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => 'dockercompose']); + expect(invokeDeploymentJobMethod($job, $reflection, 'generate_docker_env_flags_for_secrets'))->toBe(''); + + expect(invokeDeploymentJobMethod($job, $reflection, 'withoutHelperBuildSecretEnvironment', 'docker compose up -d')) + ->toBe('docker compose up -d'); +}); + +it('gives build secrets the stored value without the quotes that Coolify adds', function () { + [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerfile']); + $application->settings()->update(['use_build_secrets' => true]); + createApplicationEnvironmentVariable($application, ['key' => 'LITERAL', 'value' => "'quoted' value", 'is_literal' => true]); + createApplicationEnvironmentVariable($application, ['key' => 'MULTILINE', 'value' => "line1\nline2", 'is_multiline' => true]); + createApplicationEnvironmentVariable($application, ['key' => 'NORMAL', 'value' => "'kept'"]); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => 'dockerfile']); + $envFlags = invokeDeploymentJobMethod($job, $reflection, 'generate_docker_env_flags_for_secrets'); + + // Read the values as the helper container gets them from `docker run -e`. + $process = new Process(['bash', '-c', 'printf "%s\n" '.$envFlags.' | sed -n "s/^\\(LITERAL\\|MULTILINE\\|NORMAL\\)=/&/p"']); + $process->mustRun(); + $output = $process->getOutput(); + + expect($output) + ->toContain("LITERAL='quoted' value\n") + ->toContain("MULTILINE=line1\n") + ->toContain("NORMAL='kept'\n"); +}); diff --git a/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php b/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php index 4dc81bc4b4..eb86f1a293 100644 --- a/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php +++ b/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php @@ -128,6 +128,21 @@ test('deploy key application keeps the base directory for every build pack', fun expect(Application::query()->sole()->base_directory)->toBe('/apps/api'); })->with(['dockerfile', 'railpack', 'static', 'dockercompose']); +test('deploy key application accepts a base directory without a leading slash', function (string $typed) { + actAsDeployKeyUser($this->admin, $this->team); + + deployKeyComponent() + ->call('setPrivateKey', $this->privateKey->id) + ->set('repository_url', 'git@example.com:owner/monorepo.git') + ->set('branch', 'main') + ->set('build_pack', 'dockerfile') + ->set('base_directory', $typed) + ->call('submit') + ->assertHasNoErrors(); + + expect(Application::query()->sole()->base_directory)->toBe('/apps/api'); +})->with(['apps/api', 'apps/api/', ' /apps/api/ ']); + test('deploy key application uses the repository root when the base directory is empty', function () { actAsDeployKeyUser($this->admin, $this->team); diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php index 93d38c78e5..a052562811 100644 --- a/tests/Feature/BackupEditValidationTest.php +++ b/tests/Feature/BackupEditValidationTest.php @@ -678,3 +678,39 @@ it('toggles the schedule from the form and notifies the page header', function ( expect((bool) $backup->fresh()->enabled)->toBeFalse(); }); + +it('lets a service database backup choose the databases to back up', function (string $image, bool $showsSelection) { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $database = ServiceDatabase::create(['service_id' => $service->id, 'name' => 'db', 'image' => $image]); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'database_type' => $database->getMorphClass(), + 'database_id' => $database->id, + 'team_id' => $this->team->id, + ]); + + $component = Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => collect()]); + + $showsSelection + ? $component->assertSee('Database selection')->assertSee('Databases to back up') + : $component->assertDontSee('Database selection'); + + if ($showsSelection) { + $component->set('databasesToBackup', 'shop')->call('submit')->assertHasNoErrors(); + expect($backup->fresh()->databases_to_backup)->toBe('shop'); + } +})->with([ + 'mariadb' => ['mariadb:11', true], + 'mysql' => ['mysql:8', true], + 'postgres' => ['postgres:16-alpine', true], + 'mongodb' => ['mongo:7', false], +]); diff --git a/tests/Feature/BackupRunSafetyTest.php b/tests/Feature/BackupRunSafetyTest.php index b058cbadab..a349afb277 100644 --- a/tests/Feature/BackupRunSafetyTest.php +++ b/tests/Feature/BackupRunSafetyTest.php @@ -361,6 +361,35 @@ it('backs up a service database without a database env variable from the backup 'mysql, selected database' => ['mysql:8', 'MYSQL_ROOT_PASSWORD=secret', ['databases_to_backup' => 'shop'], 'shop'], ]); +it('backs up the databases from the backup settings instead of the container env database', function (string $image, string $env) { + $database = backupRunSafetyServiceDatabase($this->server, $this->destination, $this->environment, $image); + $backup = backupRunSafetySchedule($database, $this->team, ['databases_to_backup' => 'shop,blog']); + backupRunSafetyFakeRemoteCommands(["docker exec '{$database->name}-{$database->service->uuid}' env" => $env]); + + (new DatabaseBackupJob($backup))->handle(); + + expect(ScheduledDatabaseBackupExecution::query()->orderBy('id')->pluck('database_name')->all())->toBe(['shop', 'blog']); +})->with([ + 'mariadb' => ['mariadb:11', "MARIADB_ROOT_PASSWORD=secret\nMARIADB_DATABASE=app"], + 'mysql' => ['mysql:8', "MYSQL_ROOT_PASSWORD=secret\nMYSQL_DATABASE=app"], + 'postgres' => ['postgres:16', "POSTGRES_PASSWORD=secret\nPOSTGRES_DB=app"], +]); + +it('backs up the container env database when the backup settings name none', function (string $image, string $env) { + $database = backupRunSafetyServiceDatabase($this->server, $this->destination, $this->environment, $image); + $backup = backupRunSafetySchedule($database, $this->team); + backupRunSafetyFakeRemoteCommands(["docker exec '{$database->name}-{$database->service->uuid}' env" => $env]); + + (new DatabaseBackupJob($backup))->handle(); + + expect(ScheduledDatabaseBackupExecution::query()->sole()->database_name)->toBe('app'); +})->with([ + 'mariadb' => ['mariadb:11', "MARIADB_ROOT_PASSWORD=secret\nMARIADB_DATABASE=app"], + 'mariadb with mysql env' => ['mariadb:11', "MARIADB_ROOT_PASSWORD=secret\nMYSQL_DATABASE=app"], + 'mysql' => ['mysql:8', "MYSQL_ROOT_PASSWORD=secret\nMYSQL_DATABASE=app"], + 'postgres' => ['postgres:16', "POSTGRES_PASSWORD=secret\nPOSTGRES_DB=app"], +]); + it('records a failed execution and notifies when no service database to back up is known', function (string $image, string $env) { $database = backupRunSafetyServiceDatabase($this->server, $this->destination, $this->environment, $image); $backup = backupRunSafetySchedule($database, $this->team); diff --git a/tests/Feature/CleanupHelperContainersJobDedupTest.php b/tests/Feature/CleanupHelperContainersJobDedupTest.php index 9fd52d4404..7a6cbde9cc 100644 --- a/tests/Feature/CleanupHelperContainersJobDedupTest.php +++ b/tests/Feature/CleanupHelperContainersJobDedupTest.php @@ -1,10 +1,16 @@ false]); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]); + foreach (['finished-uuid' => ApplicationDeploymentStatus::FINISHED, 'failed-uuid' => ApplicationDeploymentStatus::FAILED, 'running-uuid' => ApplicationDeploymentStatus::IN_PROGRESS] as $uuid => $status) { + ApplicationDeploymentQueue::create(['application_id' => '1', 'deployment_uuid' => $uuid, 'server_id' => $server->id, 'status' => $status->value]); + } + $containers = collect([ + 'finished-uuid' => 'aaa111', + 'failed-uuid' => 'bbb222', + 'running-uuid' => 'ccc333', + 'volume-upload-abc' => 'ddd444', + 'volume-backup-abc' => 'eee555', + 'backup-of-abc' => 'fff666', + ])->map(fn (string $id, string $name): array => ['ID' => $id, 'Names' => $name, 'Image' => 'ghcr.io/coollabsio/coolify-helper:1.0.13'])->values(); + + $removed = []; + Process::fake(function (PendingProcess $process) use ($containers, &$removed) { + $script = remoteProcessScript($process); + if (preg_match("/docker container rm -f '?([a-z0-9]+)/", $script, $match) === 1) { + $removed[] = $match[1]; + } + + return Process::result(str_contains($script, 'docker container ps') ? $containers->toJson() : ''); + }); + + (new CleanupHelperContainersJob($server))->handle(); + + expect($removed)->toBe(['aaa111', 'bbb222']); +}); diff --git a/tests/Feature/EnvironmentVariable/MultilineEnvironmentVariableTest.php b/tests/Feature/EnvironmentVariable/MultilineEnvironmentVariableTest.php index f71811b24a..4f9fed3fe7 100644 --- a/tests/Feature/EnvironmentVariable/MultilineEnvironmentVariableTest.php +++ b/tests/Feature/EnvironmentVariable/MultilineEnvironmentVariableTest.php @@ -16,7 +16,7 @@ test('generateDockerBuildArgs returns only keys without values', function () { test('generateDockerBuildArgs works with collection of objects', function () { $variables = collect([ (object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false], - (object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true], + (object) ['key' => 'VAR2', 'value' => "multiline\nvalue"], ]); $buildArgs = generateDockerBuildArgs($variables); @@ -59,7 +59,7 @@ test('generateDockerBuildArgs handles variables without is_multiline', function test('generateDockerEnvFlags produces correct format', function () { $variables = [ ['key' => 'NORMAL_VAR', 'value' => 'value', 'is_multiline' => false], - ['key' => 'MULTILINE_VAR', 'value' => "'line1\nline2'", 'is_multiline' => true], + ['key' => 'MULTILINE_VAR', 'value' => "line1\nline2"], ]; $envFlags = generateDockerEnvFlags($variables); @@ -73,7 +73,7 @@ test('generateDockerEnvFlags produces correct format', function () { test('generateDockerEnvFlags works with collection input', function () { $variables = collect([ (object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false], - (object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true], + (object) ['key' => 'VAR2', 'value' => "multiline\nvalue"], ]); $envFlags = generateDockerEnvFlags($variables); diff --git a/tests/Feature/EnvironmentVariableLegacyEscapingTest.php b/tests/Feature/EnvironmentVariableLegacyEscapingTest.php index 96382a231c..6823dfc7b5 100644 --- a/tests/Feature/EnvironmentVariableLegacyEscapingTest.php +++ b/tests/Feature/EnvironmentVariableLegacyEscapingTest.php @@ -95,6 +95,40 @@ test('legacy service variables keep the old env file format', function () { ->and($this->service->composeEnvironmentFileLine($empty))->toBe('EMPTY='); }); +test('legacy service variables keep the value that the compose file got before exact escaping', function () { + $this->service->update(['docker_compose_raw' => <<<'YAML' +services: + app: + image: alpine + environment: + - WITH_DEFAULT=${WITH_DEFAULT:-x} + - OTHER_NAME=${RENAMED} + - NESTED=${NESTED:-${NESTED_DEFAULT}} + - PLAIN=${PLAIN} +YAML]); + $variables = collect(['WITH_DEFAULT', 'RENAMED', 'NESTED_DEFAULT', 'PLAIN']) + ->mapWithKeys(fn (string $key) => [$key => legacyVariable($this->service, ['key' => $key, 'value' => 'a\\.b'])]); + + expect($this->service->composeEnvironmentFileLine($variables['WITH_DEFAULT']))->toBe('WITH_DEFAULT="a\\\\.b"') + ->and($this->service->composeEnvironmentFileLine($variables['RENAMED']))->toBe('RENAMED="a\\\\.b"') + ->and($this->service->composeEnvironmentFileLine($variables['NESTED_DEFAULT']))->toBe('NESTED_DEFAULT="a\\\\.b"') + ->and($this->service->composeEnvironmentFileLine($variables['PLAIN']))->toBe('PLAIN=a\\\\.b') + ->and($this->service->legacyEscapingChangesValue($variables['WITH_DEFAULT']))->toBeFalse() + ->and($this->service->legacyEscapingChangesValue($variables['PLAIN']))->toBeTrue() + ->and($variables['WITH_DEFAULT']->fresh()->real_value)->toBe('a\\.b'); +}); + +test('legacy literal and multiline variables with a single quote get a line that docker compose can read', function () { + $literal = legacyVariable($this->service, ['key' => 'LITERAL_QUOTE', 'value' => "it's", 'is_literal' => true]); + $multiline = legacyVariable($this->service, ['key' => 'MULTILINE_QUOTE', 'value' => "it's\n\"two\"", 'is_multiline' => true]); + $withoutQuote = legacyVariable($this->service, ['key' => 'LITERAL_PLAIN', 'value' => 'x$y', 'is_literal' => true]); + + expect($this->service->composeEnvironmentFileLine($literal))->toBe('LITERAL_QUOTE="it\'s"') + ->and($this->service->composeEnvironmentFileLine($multiline))->toBe("MULTILINE_QUOTE=\"it's\n\\\"two\\\"\"") + ->and($this->service->composeEnvironmentFileLine($withoutQuote))->toBe("LITERAL_PLAIN='x\$y'") + ->and($this->service->legacyEscapingChangesValue($literal))->toBeFalse(); +}); + test('legacy database variables keep the old compose environment format', function () { $database = StandalonePostgresql::forceCreate([ 'uuid' => 'postgres-legacy-test', diff --git a/tests/Feature/EnvironmentVariableSearchTest.php b/tests/Feature/EnvironmentVariableSearchTest.php index 67c713cad1..9b522e060c 100644 --- a/tests/Feature/EnvironmentVariableSearchTest.php +++ b/tests/Feature/EnvironmentVariableSearchTest.php @@ -396,6 +396,45 @@ YAML; ->and(Yaml::parse($service->fresh()->docker_compose_raw))->toBe(Yaml::parse($dockerCompose)); }); +it('stores the Compose default for a variable that another service references without one', function (string $dockerCompose) { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id, + 'docker_compose_raw' => $dockerCompose, + ]); + + $service->parse(); + + expect($service->environment_variables()->where('key', 'POSTGRES_DB')->value('value'))->toBe('umami'); +})->with([ + 'reference before default' => "services:\n app:\n image: nginx\n environment:\n DATABASE_URL: postgres://user:pass@postgresql:5432/\$POSTGRES_DB\n postgresql:\n image: postgres:16-alpine\n environment:\n POSTGRES_DB: \${POSTGRES_DB:-umami}\n", + 'default before reference' => "services:\n postgresql:\n image: postgres:16-alpine\n environment:\n POSTGRES_DB: \${POSTGRES_DB:-umami}\n app:\n image: nginx\n environment:\n DATABASE_URL: postgres://user:pass@postgresql:5432/\${POSTGRES_DB}\n", +]); + +it('does not store a nested Compose default for a bare reference', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id, + 'docker_compose_raw' => "services:\n app:\n image: alpine\n environment:\n DB: \${DB:-\${APP_NAME}}\n URL: postgres://db/\$DB\n", + ]); + + $service->parse(); + + expect($service->environment_variables()->where('key', 'DB')->firstOrFail()->value)->toBeNull(); +}); + +it('does not store a Compose default when the defaults for a bare reference conflict', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id, + 'docker_compose_raw' => "services:\n app:\n image: alpine\n environment:\n LEVEL: \${LEVEL:-info}\n LOG: level=\$LEVEL\n worker:\n image: alpine\n environment:\n LEVEL: \${LEVEL:-warn}\n", + ]); + + $service->parse(); + + expect($service->environment_variables()->where('key', 'LEVEL')->firstOrFail()->value)->toBeNull(); +}); + it('keeps editable and literal rows when services use the same key differently', function () { $service = Service::factory()->create([ 'environment_id' => $this->environment->id, diff --git a/tests/Feature/S3StorageMinioClientResolveTest.php b/tests/Feature/S3StorageMinioClientResolveTest.php new file mode 100644 index 0000000000..69fe5e0967 --- /dev/null +++ b/tests/Feature/S3StorageMinioClientResolveTest.php @@ -0,0 +1,89 @@ + InstanceSettings::query()->updateOrCreate(['id' => 0], [ + 'webhook_allowed_internal_hosts' => ['localhost'], + 'webhook_allow_localhost' => true, + ])); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +/** + * @return array{0: Server, 1: S3Storage} + */ +function minioResolveTestSetup(string $endpoint, string $sshUser = 'root'): array +{ + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'user' => $sshUser, + 'ip' => '203.0.113.10', + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $storage = S3Storage::create([ + 'name' => 'Destination', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'bucket', + 'endpoint' => $endpoint, + 'team_id' => $team->id, + 'is_usable' => true, + ]); + + return [$server, $storage]; +} + +it('pins the IPv6 address of a dual-stack S3 host when only IPv6 connects from the upload network', function () { + [$server, $storage] = minioResolveTestSetup('http://localhost:9000'); + fakeRemoteProcesses(['*/dev/tcp/*' => '::1', '*' => '']); + + $options = $storage->minioClientResolveOptions($server, 'ghcr.io/coollabsio/coolify-helper:1.0.0', 'coolify'); + + expect($options)->toBe(" --resolve 'localhost:9000=::1'"); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker run --rm --network 'coolify' 'ghcr.io/coollabsio/coolify-helper:1.0.0' bash -c") + && str_contains(remoteProcessScript($process), "for ip in '\\''127.0.0.1'\\'' '\\''::1'\\''; do") + && str_contains(remoteProcessScript($process), '/dev/tcp/$ip/9000')); +}); + +it('pins the IPv4 address when it connects or when no address connects', function (string $probeOutput) { + [$server, $storage] = minioResolveTestSetup('http://localhost:9000'); + fakeRemoteProcesses(['*/dev/tcp/*' => $probeOutput, '*' => '']); + + expect($storage->minioClientResolveOptions($server, 'helper:1.0.0'))->toBe(" --resolve 'localhost:9000=127.0.0.1'"); +})->with([ + 'IPv4 connects' => '127.0.0.1', + 'no address connects' => '', + 'unexpected output' => '10.0.0.1', +]); + +it('runs the probe with sudo on servers with a non-root SSH user', function () { + [$server, $storage] = minioResolveTestSetup('http://localhost:9000', 'deploy'); + fakeRemoteProcesses(['*/dev/tcp/*' => '::1', '*' => '']); + + expect($storage->minioClientResolveOptions($server, 'helper:1.0.0'))->toBe(" --resolve 'localhost:9000=::1'"); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "sudo docker run --rm 'helper:1.0.0' bash -c") + && str_contains(remoteProcessScript($process), 'if timeout 5 bash -c "exec 3<>/dev/tcp/$ip/9000" 2>/dev/null; then echo "$ip"; exit 0; fi; done')); +}); + +it('does not pin or probe an S3 endpoint that is an IP address', function () { + [$server, $storage] = minioResolveTestSetup('https://8.8.8.8'); + Process::fake(); + + expect($storage->minioClientResolveOptions($server, 'helper:1.0.0'))->toBe(''); + Process::assertNothingRan(); +}); diff --git a/tests/Feature/ServerMetricsPrefetchTest.php b/tests/Feature/ServerMetricsPrefetchTest.php index 12a5cb6078..6d84e68b35 100644 --- a/tests/Feature/ServerMetricsPrefetchTest.php +++ b/tests/Feature/ServerMetricsPrefetchTest.php @@ -52,20 +52,20 @@ it('keeps the Sentinel error of one prefetched metric', function () { ->and(fn () => $this->server->getMemoryMetrics(5))->toThrow(Exception::class, 'Unauthorized'); }); -it('falls back to one fetch per metric when the batch fails', function () { +it('does not retry an unavailable server after the batch fails', function () { $commands = []; Process::fake(function (PendingProcess $process) use (&$commands) { $commands[] = remoteProcessScript($process); - return count($commands) === 1 - ? Process::result('', 'ssh: connect to host failed', 1) - : Process::result('[{"time":2000,"percent":5}]'); + return Process::result('', 'ssh: connect to host failed', 1); }); $this->server->prefetchMetrics(['cpu', 'memory'], 5); - expect($this->server->getCpuMetrics(5))->toBe([[2000, 5.0]]) - ->and($commands)->toHaveCount(2); + expect($this->server->getCpuMetrics(5))->toBeNull() + ->and($this->server->getMemoryMetrics(5))->toBeNull() + ->and($this->server->getLoadMetrics(5))->toBeNull() + ->and($commands)->toHaveCount(1); }); it('quotes the bucketed history URL in a single fetch for a non-root user', function () { @@ -96,7 +96,7 @@ it('runs the batched metrics script with sudo for a non-root user', function () expect($commands)->toHaveCount(1) ->and($commands[0])->toContain('sudo docker exec -i coolify-sentinel sh -s') - ->and($commands[0])->toContain("\ncurl -s -H @- ") + ->and($commands[0])->toContain("\ncurl -sS --connect-timeout 2 --max-time 3 -H @- ") ->and($commands[0])->not->toContain('sudo curl') ->and($commands[0])->not->toContain('sudo printf'); }); diff --git a/tests/Feature/StartDatabaseImportS3CredentialsTest.php b/tests/Feature/StartDatabaseImportS3CredentialsTest.php index 15b0041535..045d5c1db9 100644 --- a/tests/Feature/StartDatabaseImportS3CredentialsTest.php +++ b/tests/Feature/StartDatabaseImportS3CredentialsTest.php @@ -282,6 +282,43 @@ function importS3ArgvTestRunAliasProcess(array $aliasProcess): array } } +it('pins the reachable IP of a dual-stack S3 host for the restore mc commands', function () { + InstanceSettings::query()->whereKey(0)->update([ + 'webhook_allowed_internal_hosts' => ['localhost'], + 'webhook_allow_localhost' => true, + ]); + $storage = S3Storage::create([ + 'name' => 'Import S3', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'test-bucket', + 'endpoint' => 'http://localhost:9000', + 'is_usable' => true, + 'team_id' => $this->team->id, + ]); + $disk = Mockery::mock(FilesystemAdapter::class); + $disk->shouldReceive('exists')->once()->andReturn(true); + $disk->shouldReceive('size')->once()->andReturn(1024); + $filesystem = Mockery::mock(FilesystemManager::class, [app()])->makePartial(); + $filesystem->shouldReceive('build')->once()->andReturn($disk); + Storage::swap($filesystem); + fakeRemoteProcesses(['*/dev/tcp/*' => '::1', '*' => '']); + Queue::fake(); + + $activity = app(StartDatabaseImport::class)->handle( + $this->database, + new DatabaseImportSource('s3', path: 'backups/restore.sql', s3StorageUuid: $storage->uuid), + $this->team->id, + ); + + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker run --rm --network 'coolify'") + && str_contains(remoteProcessScript($process), '/dev/tcp/$ip/9000')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "mc alias set --resolve '\\''localhost:9000=::1'\\'' --api S3v4 s3temp")); + expect((string) RemoteProcessCommand::read($activity)) + ->toContain("mc cp --resolve 'localhost:9000=::1' ".escapeshellarg('s3temp/test-bucket/backups/restore.sql')); +}); + it('passes the S3 keys of an import to mc on stdin, not in a command line', function (string $sshUser) { [$processes, $activity, $error] = importS3ArgvTestRun($this, $sshUser); diff --git a/tests/Feature/VolumeCloneJobTest.php b/tests/Feature/VolumeCloneJobTest.php index b0f6e627d4..9df9cb6e85 100644 --- a/tests/Feature/VolumeCloneJobTest.php +++ b/tests/Feature/VolumeCloneJobTest.php @@ -6,6 +6,8 @@ use App\Models\InstanceSettings; use App\Models\LocalPersistentVolume; use App\Models\PrivateKey; use App\Models\Server; +use App\Models\Service; +use App\Models\ServiceApplication; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Process; @@ -192,3 +194,32 @@ test('a non-root server runs the volume create as one sudo shell script', functi expect(volumeCloneCommands())->toContain('sudo sh -c '.escapeshellarg($inner)) ->and(volumeCloneCommands())->toContain('sudo docker volume inspect'); }); + +test('a service clone creates the target volume with the Compose labels of the service project', function () { + $service = Service::factory()->create(); + $serviceApplication = ServiceApplication::create(['name' => 'gitea', 'service_id' => $service->id]); + $this->volume->update(['resource_type' => ServiceApplication::class, 'resource_id' => $serviceApplication->id]); + fakeVolumeCloneServer(['Name' => 'clone-source', 'Driver' => 'local', 'Options' => null]); + + (new VolumeCloneJob('clone-source', 'clone-target', $this->server, $this->server, $this->volume->fresh()))->handle(); + + expect(volumeCloneCommands())->toContain(escapeshellarg( + "docker volume create --label 'com.docker.compose.project={$service->uuid}' --label 'com.docker.compose.volume=clone-target' 'clone-target'" + )); +}); + +test('an application clone creates the target volume with the Compose labels of the application project', function () { + $application = Application::factory()->create(); + $this->volume->update(['resource_type' => Application::class, 'resource_id' => $application->id]); + fakeVolumeCloneServer([ + 'Name' => 'clone-source', + 'Driver' => 'local', + 'Options' => ['type' => 'none', 'o' => 'bind', 'device' => '/srv/app-data'], + ]); + + (new VolumeCloneJob('clone-source', 'clone-target', $this->server, $this->server, $this->volume->fresh()))->handle(); + + expect(volumeCloneCommands())->toContain(escapeshellarg( + "docker volume create --label 'com.docker.compose.project={$application->uuid}' --label 'com.docker.compose.volume=clone-target' 'clone-target'" + )); +}); diff --git a/tests/Unit/SafeWebhookUrlTest.php b/tests/Unit/SafeWebhookUrlTest.php index 83d8c9557c..abd7e85937 100644 --- a/tests/Unit/SafeWebhookUrlTest.php +++ b/tests/Unit/SafeWebhookUrlTest.php @@ -278,17 +278,16 @@ it('builds MinIO client resolve options for S3 backup uploads', function () { expect($options)->toContain('localhost:9000=127.0.0.1'); }); -it('collapses dual-stack S3 hosts to a single bracket-free IPv4 resolve entry', function () { +it('lists every S3 host IP as a bracket-free resolve entry, IPv4 first', function () { InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [ 'webhook_allowed_internal_hosts' => ['localhost'], 'webhook_allow_localhost' => true, ])); - // localhost resolves to both 127.0.0.1 and ::1. The MinIO client keeps one IP per - // host and rejects bracketed IPv6, so exactly one bracket-free IPv4 entry is produced. + // mc rejects bracketed IPv6, and S3Storage::minioClientResolveOptions() picks the reachable entry. $options = SafeWebhookUrl::minioClientResolveOptions('http://localhost:9000'); - expect($options)->toBe(['localhost:9000=127.0.0.1']); + expect($options)->toBe(['localhost:9000=127.0.0.1', 'localhost:9000=::1']); }); it('rejects trailing-dot hostnames to avoid DNS pinning mismatch', function () { diff --git a/tests/Unit/SharedEnvironmentVariablesOpenApiTest.php b/tests/Unit/SharedEnvironmentVariablesOpenApiTest.php new file mode 100644 index 0000000000..1ed2a710af --- /dev/null +++ b/tests/Unit/SharedEnvironmentVariablesOpenApiTest.php @@ -0,0 +1,37 @@ +toBeTrue() + ->and($operation['requestBody']['content']['application/json']['schema']['$ref'] ?? null) + ->toBe("#/components/schemas/{$schemaName}"); +})->with([ + ['/team/envs', 'post', 'SharedEnvironmentVariableCreateRequest'], + ['/team/envs/{env_id}', 'patch', 'SharedEnvironmentVariableUpdateRequest'], + ['/projects/{uuid}/envs', 'post', 'SharedEnvironmentVariableCreateRequest'], + ['/projects/{uuid}/envs/{env_id}', 'patch', 'SharedEnvironmentVariableUpdateRequest'], + ['/projects/{uuid}/environments/{environment_name_or_uuid}/envs', 'post', 'SharedEnvironmentVariableCreateRequest'], + ['/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', 'patch', 'SharedEnvironmentVariableUpdateRequest'], + ['/servers/{uuid}/envs', 'post', 'SharedEnvironmentVariableCreateRequest'], + ['/servers/{uuid}/envs/{env_id}', 'patch', 'SharedEnvironmentVariableUpdateRequest'], +]); + +it('documents the fields accepted by the shared env validator', function () { + $schemas = sharedEnvOpenApiSpec()['components']['schemas']; + $fields = ['key', 'value', 'is_literal', 'is_multiline', 'is_shown_once', 'comment']; + + expect(array_keys($schemas['SharedEnvironmentVariableCreateRequest']['properties']))->toBe($fields) + ->and($schemas['SharedEnvironmentVariableCreateRequest']['required'])->toBe(['key']) + ->and(array_keys($schemas['SharedEnvironmentVariableUpdateRequest']['properties']))->toBe($fields) + ->and($schemas['SharedEnvironmentVariableUpdateRequest'])->not->toHaveKey('required'); +});