Merge remote-tracking branch 'origin/next' into pr-7764-shadow/add-shared-server-env

This commit is contained in:
Andras Bacsai committed 2026-03-31 10:52:31 +02:00
commit cb97a18a78
767 files changed
+61894 -15664

No files matched your search

+33 -18
View File
@@ -86,27 +86,32 @@ function sharedDataApplications()
'git_branch' => 'string',
'build_pack' => Rule::enum(BuildPackTypes::class),
'is_static' => 'boolean',
'is_spa' => 'boolean',
'is_auto_deploy_enabled' => 'boolean',
'is_force_https_enabled' => 'boolean',
'static_image' => Rule::enum(StaticImageTypes::class),
'domains' => 'string',
'domains' => 'string|nullable',
'redirect' => Rule::enum(RedirectTypes::class),
'git_commit_sha' => 'string',
'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'docker_registry_image_name' => 'string|nullable',
'docker_registry_image_tag' => 'string|nullable',
'install_command' => 'string|nullable',
'build_command' => 'string|nullable',
'start_command' => 'string|nullable',
'install_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/',
'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable',
'custom_network_aliases' => 'string|nullable',
'base_directory' => 'string|nullable',
'publish_directory' => 'string|nullable',
'base_directory' => \App\Support\ValidationPatterns::directoryPathRules(),
'publish_directory' => \App\Support\ValidationPatterns::directoryPathRules(),
'health_check_enabled' => 'boolean',
'health_check_path' => 'string',
'health_check_port' => 'string|nullable',
'health_check_host' => 'string',
'health_check_method' => 'string',
'health_check_type' => 'string|in:http,cmd',
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
'health_check_port' => 'integer|nullable|min:1|max:65535',
'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS',
'health_check_return_code' => 'numeric',
'health_check_scheme' => 'string',
'health_check_scheme' => 'string|in:http,https',
'health_check_response_text' => 'string|nullable',
'health_check_interval' => 'numeric',
'health_check_timeout' => 'numeric',
@@ -120,21 +125,26 @@ function sharedDataApplications()
'limits_cpuset' => 'string|nullable',
'limits_cpu_shares' => 'numeric',
'custom_labels' => 'string|nullable',
'custom_docker_run_options' => 'string|nullable',
'custom_docker_run_options' => \App\Support\ValidationPatterns::shellSafeCommandRules(2000),
// Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate").
// Access is gated by API token authentication. Commands run inside the app container, not the host.
'post_deployment_command' => 'string|nullable',
'post_deployment_command_container' => 'string',
'post_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(),
'pre_deployment_command' => 'string|nullable',
'pre_deployment_command_container' => 'string',
'pre_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(),
'manual_webhook_secret_github' => 'string|nullable',
'manual_webhook_secret_gitlab' => 'string|nullable',
'manual_webhook_secret_bitbucket' => 'string|nullable',
'manual_webhook_secret_gitea' => 'string|nullable',
'docker_compose_location' => 'string',
'dockerfile_location' => \App\Support\ValidationPatterns::filePathRules(),
'dockerfile_target_build' => \App\Support\ValidationPatterns::dockerTargetRules(),
'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
'docker_compose' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_custom_start_command' => 'string|nullable',
'docker_compose_custom_build_command' => 'string|nullable',
'docker_compose_custom_start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'docker_compose_custom_build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'is_container_label_escape_enabled' => 'boolean',
'is_preserve_repository_enabled' => 'boolean'
];
}
@@ -177,8 +187,13 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('private_key_uuid');
$request->offsetUnset('use_build_server');
$request->offsetUnset('is_static');
$request->offsetUnset('is_spa');
$request->offsetUnset('is_auto_deploy_enabled');
$request->offsetUnset('is_force_https_enabled');
$request->offsetUnset('connect_to_docker_network');
$request->offsetUnset('force_domain_override');
$request->offsetUnset('autogenerate_domain');
$request->offsetUnset('is_container_label_escape_enabled');
$request->offsetUnset('is_preserve_repository_enabled');
$request->offsetUnset('docker_compose_raw');
}
+22 -13
View File
@@ -6,12 +6,13 @@ use App\Jobs\ApplicationDeploymentJob;
use App\Jobs\VolumeCloneJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\EnvironmentVariable;
use App\Models\Server;
use App\Models\StandaloneDocker;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false)
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null)
{
$application_id = $application->id;
$deployment_link = Url::fromString($application->link()."/deployment/{$deployment_uuid}");
@@ -46,6 +47,7 @@ function queue_application_deployment(Application $application, string $deployme
$existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id)
->where('commit', $commit)
->where('pull_request_id', $pull_request_id)
->where('docker_registry_image_tag', $docker_registry_image_tag)
->whereIn('status', [ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value])
->first();
@@ -71,6 +73,7 @@ function queue_application_deployment(Application $application, string $deployme
'deployment_uuid' => $deployment_uuid,
'deployment_url' => $deployment_url,
'pull_request_id' => $pull_request_id,
'docker_registry_image_tag' => $docker_registry_image_tag,
'force_rebuild' => $force_rebuild,
'is_webhook' => $is_webhook,
'is_api' => $is_api,
@@ -191,6 +194,10 @@ function clone_application(Application $source, $destination, array $overrides =
$uuid = $overrides['uuid'] ?? (string) new Cuid2;
$server = $destination->server;
if ($server->team_id !== currentTeam()->id) {
throw new RuntimeException('Destination does not belong to the current team.');
}
// Prepare name and URL
$name = $overrides['name'] ?? 'clone-of-'.str($source->name)->limit(20).'-'.$uuid;
$applicationSettings = $source->settings;
@@ -207,7 +214,7 @@ function clone_application(Application $source, $destination, array $overrides =
'updated_at',
'additional_servers_count',
'additional_networks_count',
])->fill(array_merge([
])->forceFill(array_merge([
'uuid' => $uuid,
'name' => $name,
'fqdn' => $url,
@@ -230,10 +237,11 @@ function clone_application(Application $source, $destination, array $overrides =
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'application_id' => $newApplication->id,
]);
$newApplicationSettings->save();
$newApplication->setRelation('settings', $newApplicationSettings->fresh());
}
// Clone tags
@@ -249,7 +257,7 @@ function clone_application(Application $source, $destination, array $overrides =
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'uuid' => (string) new Cuid2,
'application_id' => $newApplication->id,
'team_id' => currentTeam()->id,
@@ -264,7 +272,7 @@ function clone_application(Application $source, $destination, array $overrides =
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'uuid' => (string) new Cuid2,
'application_id' => $newApplication->id,
'status' => 'exited',
@@ -295,7 +303,8 @@ function clone_application(Application $source, $destination, array $overrides =
'id',
'created_at',
'updated_at',
])->fill([
'uuid',
])->forceFill([
'name' => $newName,
'resource_id' => $newApplication->id,
]);
@@ -318,8 +327,8 @@ function clone_application(Application $source, $destination, array $overrides =
destination: $source->destination,
no_questions_asked: true
);
} catch (\Exception $e) {
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
} catch (Exception $e) {
Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
}
}
}
@@ -331,7 +340,7 @@ function clone_application(Application $source, $destination, array $overrides =
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'resource_id' => $newApplication->id,
]);
$newStorage->save();
@@ -340,12 +349,12 @@ function clone_application(Application $source, $destination, array $overrides =
// Clone production environment variables without triggering the created hook
$environmentVariables = $source->environment_variables()->get();
foreach ($environmentVariables as $environmentVariable) {
\App\Models\EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) {
EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) {
$newEnvironmentVariable = $environmentVariable->replicate([
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'resourceable_id' => $newApplication->id,
'resourceable_type' => $newApplication->getMorphClass(),
'is_preview' => false,
@@ -357,12 +366,12 @@ function clone_application(Application $source, $destination, array $overrides =
// Clone preview environment variables
$previewEnvironmentVariables = $source->environment_variables_preview()->get();
foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) {
\App\Models\EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) {
EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) {
$newPreviewEnvironmentVariable = $previewEnvironmentVariable->replicate([
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'resourceable_id' => $newApplication->id,
'resourceable_type' => $newApplication->getMorphClass(),
'is_preview' => true,
+10 -3
View File
@@ -137,10 +137,16 @@ function checkMinimumDockerEngineVersion($dockerVersion)
return $dockerVersion;
}
function escapeShellValue(string $value): string
{
return "'".str_replace("'", "'\\''", $value)."'";
}
function executeInDocker(string $containerId, string $command)
{
return "docker exec {$containerId} bash -c '{$command}'";
// return "docker exec {$this->deployment_uuid} bash -c '{$command} |& tee -a /proc/1/fd/1; [ \$PIPESTATUS -eq 0 ] || exit \$PIPESTATUS'";
$escapedCommand = str_replace("'", "'\\''", $command);
return "docker exec {$containerId} bash -c '{$escapedCommand}'";
}
function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false)
@@ -231,7 +237,7 @@ function defaultLabels($id, $name, string $projectName, string $resourceName, st
$labels->push('coolify.version='.config('constants.coolify.version'));
$labels->push('coolify.'.$type.'Id='.$id);
$labels->push("coolify.type=$type");
$labels->push('coolify.name='.$name);
$labels->push('coolify.name='.Str::slug($name));
$labels->push('coolify.resourceName='.Str::slug($resourceName));
$labels->push('coolify.projectName='.Str::slug($projectName));
$labels->push('coolify.serviceName='.Str::slug($subName ?? $resourceName));
@@ -1005,6 +1011,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
'--ulimit' => 'ulimits',
'--privileged' => 'privileged',
'--ip' => 'ip',
'--ip6' => 'ip6',
'--shm-size' => 'shm_size',
'--gpus' => 'gpus',
'--hostname' => 'hostname',
+45 -18
View File
@@ -158,8 +158,7 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te
return str($domain);
});
// Check applications within the same team
$applications = Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid', 'name', 'id']);
$applications = Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid', 'name', 'id', 'docker_compose_domains', 'build_pack']);
$serviceApplications = ServiceApplication::ownedByCurrentTeamAPI($teamId)->with('service:id,name')->get(['fqdn', 'uuid', 'id', 'service_id']);
if ($uuid) {
@@ -168,23 +167,51 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te
}
foreach ($applications as $app) {
if (is_null($app->fqdn)) {
continue;
}
$list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
foreach ($list_of_domains as $domain) {
if (str($domain)->endsWith('/')) {
$domain = str($domain)->beforeLast('/');
if (! is_null($app->fqdn)) {
$list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
foreach ($list_of_domains as $domain) {
if (str($domain)->endsWith('/')) {
$domain = str($domain)->beforeLast('/');
}
$naked_domain = str($domain)->value();
if ($domains->contains($naked_domain)) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->name,
'resource_uuid' => $app->uuid,
'resource_type' => 'application',
'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
];
}
}
$naked_domain = str($domain)->value();
if ($domains->contains($naked_domain)) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->name,
'resource_uuid' => $app->uuid,
'resource_type' => 'application',
'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
];
}
if ($app->build_pack === 'dockercompose' && ! empty($app->docker_compose_domains)) {
$dockerComposeDomains = json_decode($app->docker_compose_domains, true);
if (is_array($dockerComposeDomains)) {
foreach ($dockerComposeDomains as $serviceName => $domainConfig) {
$domainValue = data_get($domainConfig, 'domain');
if (empty($domainValue)) {
continue;
}
$list_of_domains = collect(explode(',', $domainValue))->filter(fn ($fqdn) => $fqdn !== '');
foreach ($list_of_domains as $domain) {
if (str($domain)->endsWith('/')) {
$domain = str($domain)->beforeLast('/');
}
$naked_domain = str($domain)->value();
if ($domains->contains($naked_domain)) {
$conflicts[] = [
'domain' => $naked_domain,
'resource_name' => $app->name,
'resource_uuid' => $app->uuid,
'resource_type' => 'application',
'service_name' => $serviceName,
'message' => "Domain $naked_domain is already in use by application '{$app->name}' (service: {$serviceName})",
];
}
}
}
}
}
}
+420 -145
View File
@@ -22,25 +22,25 @@ use Visus\Cuid2\Cuid2;
*
* @param string $composeYaml The raw Docker Compose YAML content
*
* @throws \Exception If the compose file contains command injection attempts
* @throws Exception If the compose file contains command injection attempts
*/
function validateDockerComposeForInjection(string $composeYaml): void
{
try {
$parsed = Yaml::parse($composeYaml);
} catch (\Exception $e) {
throw new \Exception('Invalid YAML format: '.$e->getMessage(), 0, $e);
} catch (Exception $e) {
throw new Exception('Invalid YAML format: '.$e->getMessage(), 0, $e);
}
if (! is_array($parsed) || ! isset($parsed['services']) || ! is_array($parsed['services'])) {
throw new \Exception('Docker Compose file must contain a "services" section');
throw new Exception('Docker Compose file must contain a "services" section');
}
// Validate service names
foreach ($parsed['services'] as $serviceName => $serviceConfig) {
try {
validateShellSafePath($serviceName, 'service name');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.',
0,
@@ -68,8 +68,8 @@ function validateDockerComposeForInjection(string $composeYaml): void
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
try {
validateShellSafePath($source, 'volume source');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.',
0,
@@ -84,8 +84,8 @@ function validateDockerComposeForInjection(string $composeYaml): void
if (is_string($target)) {
try {
validateShellSafePath($target, 'volume target');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.',
0,
@@ -105,7 +105,7 @@ function validateDockerComposeForInjection(string $composeYaml): void
*
* @param string $volumeString The volume string to validate
*
* @throws \Exception If the volume string contains command injection attempts
* @throws Exception If the volume string contains command injection attempts
*/
function validateVolumeStringForInjection(string $volumeString): void
{
@@ -325,9 +325,9 @@ function parseDockerVolumeString(string $volumeString): array
if (! $isSimpleEnvVar && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceStr, 'volume source');
} catch (\Exception $e) {
} catch (Exception $e) {
// Re-throw with more context about the volume string
throw new \Exception(
throw new Exception(
'Invalid Docker volume definition: '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
@@ -343,8 +343,8 @@ function parseDockerVolumeString(string $volumeString): array
// Still, defense in depth is important
try {
validateShellSafePath($targetStr, 'volume target');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition: '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
@@ -375,7 +375,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
try {
$yaml = Yaml::parse($compose);
} catch (\Exception) {
} catch (Exception) {
return collect([]);
}
$services = data_get($yaml, 'services', collect([]));
@@ -409,8 +409,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
// Validate service name for command injection
try {
validateShellSafePath($serviceName, 'service name');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.'
);
@@ -442,9 +442,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$value = str($value);
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
preg_match_all($regex, $value, $valueMatches);
if (count($valueMatches[1]) > 0) {
foreach ($valueMatches[1] as $match) {
$match = replaceVariables($match);
if (count($valueMatches[2]) > 0) {
foreach ($valueMatches[2] as $match) {
$match = str($match);
if ($match->startsWith('SERVICE_')) {
if ($magicEnvironments->has($match->value())) {
continue;
@@ -465,7 +465,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version);
}
if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
$fqdn = "$fqdn$path";
@@ -738,8 +738,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceValue, 'volume source');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
@@ -749,8 +749,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if ($target !== null && ! empty($target->value())) {
try {
validateShellSafePath($target->value(), 'volume target');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
@@ -789,7 +789,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
}
$source = replaceLocalSource($source, $mainDirectory);
if ($isPullRequest) {
$isPreviewSuffixEnabled = $foundConfig
? (bool) data_get($foundConfig, 'is_preview_suffix_enabled', true)
: true;
if ($isPullRequest && $isPreviewSuffixEnabled) {
$source = addPreviewDeploymentSuffix($source, $pull_request_id);
}
LocalFileVolume::updateOrCreate(
@@ -986,65 +989,155 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
continue;
}
if ($key->value() === $parsedValue->value()) {
$value = null;
// Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL})
// Ensure the variable exists in DB for .env generation and UI display
$resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
]);
// Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time.
// Do NOT replace with DB value: if user updates env var without re-parsing compose,
// a stale resolved value in environment: would override the correct .env value.
} else {
if ($value->startsWith('$')) {
$isRequired = false;
if ($value->contains(':-')) {
$value = replaceVariables($value);
$key = $value->before(':');
$value = $value->after(':-');
} elseif ($value->contains('-')) {
$value = replaceVariables($value);
$key = $value->before('-');
$value = $value->after('-');
} elseif ($value->contains(':?')) {
$value = replaceVariables($value);
// Extract variable content between ${...} using balanced brace matching
$result = extractBalancedBraceContent($value->value(), 0);
$key = $value->before(':');
$value = $value->after(':?');
$isRequired = true;
} elseif ($value->contains('?')) {
$value = replaceVariables($value);
if ($result !== null) {
$content = $result['content'];
$split = splitOnOperatorOutsideNested($content);
$key = $value->before('?');
$value = $value->after('?');
$isRequired = true;
}
if ($originalValue->value() === $value->value()) {
// This means the variable does not have a default value, so it needs to be created in Coolify
$parsedKeyValue = replaceVariables($value);
if ($split !== null) {
// Has default value syntax (:-, -, :?, or ?)
$varName = $split['variable'];
$operator = $split['operator'];
$defaultValue = $split['default'];
$isRequired = str_contains($operator, '?');
// Create the primary variable with its default (only if it doesn't exist)
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $varName,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $defaultValue,
'is_preview' => false,
'is_required' => $isRequired,
]);
// Add the variable to the environment so it will be shown in the deployable compose file
$environment[$varName] = $envVar->value;
// Recursively process nested variables in default value
if (str_contains($defaultValue, '${')) {
$searchPos = 0;
$nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
while ($nestedResult !== null) {
$nestedContent = $nestedResult['content'];
$nestedSplit = splitOnOperatorOutsideNested($nestedContent);
// Determine the nested variable name
$nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent;
// Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system
$isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_');
if (! $isMagicVariable) {
if ($nestedSplit !== null) {
$nestedEnvVar = $resource->environment_variables()->firstOrCreate([
'key' => $nestedSplit['variable'],
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $nestedSplit['default'],
'is_preview' => false,
]);
$environment[$nestedSplit['variable']] = $nestedEnvVar->value;
} else {
$nestedEnvVar = $resource->environment_variables()->firstOrCreate([
'key' => $nestedContent,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
]);
$environment[$nestedContent] = $nestedEnvVar->value;
}
}
$searchPos = $nestedResult['end'] + 1;
if ($searchPos >= strlen($defaultValue)) {
break;
}
$nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
}
}
} else {
// Simple variable reference without default
$parsedKeyValue = replaceVariables($value);
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $content,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
'is_required' => $isRequired,
]);
// Add the variable to the environment using the saved DB value
$environment[$content] = $envVar->value;
}
} else {
// Fallback to old behavior for malformed input (backward compatibility)
if ($value->contains(':-')) {
$value = replaceVariables($value);
$key = $value->before(':');
$value = $value->after(':-');
} elseif ($value->contains('-')) {
$value = replaceVariables($value);
$key = $value->before('-');
$value = $value->after('-');
} elseif ($value->contains(':?')) {
$value = replaceVariables($value);
$key = $value->before(':');
$value = $value->after(':?');
$isRequired = true;
} elseif ($value->contains('?')) {
$value = replaceVariables($value);
$key = $value->before('?');
$value = $value->after('?');
$isRequired = true;
}
if ($originalValue->value() === $value->value()) {
// This means the variable does not have a default value
$parsedKeyValue = replaceVariables($value);
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $parsedKeyValue,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
'is_required' => $isRequired,
]);
// Add the variable to the environment using the saved DB value
$environment[$parsedKeyValue->value()] = $envVar->value;
continue;
}
$resource->environment_variables()->firstOrCreate([
'key' => $parsedKeyValue,
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'is_required' => $isRequired,
]);
// Add the variable to the environment so it will be shown in the deployable compose file
$environment[$parsedKeyValue->value()] = $value;
continue;
}
$resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'is_required' => $isRequired,
]);
}
}
}
@@ -1205,6 +1298,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
// Otherwise keep empty string as-is
}
// Resolve shared variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}}
// Without this, literal {{...}} strings end up in the compose environment: section,
// which takes precedence over the resolved values in the .env file (env_file:)
if (is_string($value) && str_contains($value, '{{')) {
$value = resolveSharedEnvironmentVariables($value, $resource);
}
return $value;
});
}
@@ -1219,21 +1319,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;
$uuid = $resource->uuid;
$network = data_get($resource, 'destination.network');
$labelUuid = $resource->uuid;
$labelNetwork = data_get($resource, 'destination.network');
if ($isPullRequest) {
$uuid = "{$resource->uuid}-{$pullRequestId}";
$labelUuid = "{$resource->uuid}-{$pullRequestId}";
}
if ($isPullRequest) {
$network = "{$resource->destination->network}-{$pullRequestId}";
$labelNetwork = "{$resource->destination->network}-{$pullRequestId}";
}
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $uuid,
uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
@@ -1243,10 +1343,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
break;
case ProxyTypes::CADDY->value:
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $network,
uuid: $uuid,
network: $labelNetwork,
uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
@@ -1258,9 +1358,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
} else {
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $uuid,
uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
@@ -1268,10 +1368,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
image: $image
));
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $network,
uuid: $uuid,
network: $labelNetwork,
uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
@@ -1389,7 +1489,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
}
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
} catch (\Exception $e) {
} catch (Exception $e) {
// If parsing fails, keep the original docker_compose_raw unchanged
ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage());
}
@@ -1411,15 +1511,30 @@ function serviceParser(Service $resource): Collection
return collect([]);
}
// Extract inline comments from raw YAML before Symfony parser discards them
$envComments = extractYamlEnvironmentComments($compose);
$server = data_get($resource, 'server');
$allServices = get_service_templates();
try {
$yaml = Yaml::parse($compose);
} catch (\Exception) {
} catch (Exception) {
return collect([]);
}
$services = data_get($yaml, 'services', collect([]));
// Clean up corrupted environment variables from previous parser bugs
// (keys starting with $ or ending with } should not exist as env var names)
$resource->environment_variables()
->where('resourceable_type', get_class($resource))
->where('resourceable_id', $resource->id)
->where(function ($q) {
$q->where('key', 'LIKE', '$%')
->orWhere('key', 'LIKE', '%}');
})
->delete();
$topLevel = collect([
'volumes' => collect(data_get($yaml, 'volumes', [])),
'networks' => collect(data_get($yaml, 'networks', [])),
@@ -1451,8 +1566,8 @@ function serviceParser(Service $resource): Collection
// Validate service name for command injection
try {
validateShellSafePath($serviceName, 'service name');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.'
);
@@ -1478,20 +1593,25 @@ function serviceParser(Service $resource): Collection
// Use image detection for non-migrated services
$isDatabase = isDatabaseImage($image, $service);
if ($isDatabase) {
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
if ($applicationFound) {
$savedService = $applicationFound;
$databaseFound = ServiceDatabase::where('name', $serviceName)->where('service_id', $resource->id)->first();
if ($databaseFound) {
$savedService = $databaseFound;
} else {
$savedService = ServiceDatabase::firstOrCreate([
$savedService = ServiceDatabase::forceCreate([
'name' => $serviceName,
'service_id' => $resource->id,
]);
}
} else {
$savedService = ServiceApplication::firstOrCreate([
'name' => $serviceName,
'service_id' => $resource->id,
]);
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
if ($applicationFound) {
$savedService = $applicationFound;
} else {
$savedService = ServiceApplication::forceCreate([
'name' => $serviceName,
'service_id' => $resource->id,
]);
}
}
}
// Update image if it changed
@@ -1597,9 +1717,9 @@ function serviceParser(Service $resource): Collection
$value = str($value);
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
preg_match_all($regex, $value, $valueMatches);
if (count($valueMatches[1]) > 0) {
foreach ($valueMatches[1] as $match) {
$match = replaceVariables($match);
if (count($valueMatches[2]) > 0) {
foreach ($valueMatches[2] as $match) {
$match = str($match);
if ($match->startsWith('SERVICE_')) {
if ($magicEnvironments->has($match->value())) {
continue;
@@ -1657,7 +1777,7 @@ function serviceParser(Service $resource): Collection
// Strip scheme for environment variable values
$fqdnValueForEnv = str($fqdn)->after('://')->value();
if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
// Only add path if it's not already present (prevents duplication on subsequent parse() calls)
@@ -1694,51 +1814,60 @@ function serviceParser(Service $resource): Collection
}
// ALWAYS create BOTH base SERVICE_URL and SERVICE_FQDN pairs (without port)
$fqdnKey = "SERVICE_FQDN_{$serviceName}";
$resource->environment_variables()->updateOrCreate([
'key' => "SERVICE_FQDN_{$serviceName}",
'key' => $fqdnKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdnValueForEnv,
'is_preview' => false,
'comment' => $envComments[$fqdnKey] ?? null,
]);
$urlKey = "SERVICE_URL_{$serviceName}";
$resource->environment_variables()->updateOrCreate([
'key' => "SERVICE_URL_{$serviceName}",
'key' => $urlKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $url,
'is_preview' => false,
'comment' => $envComments[$urlKey] ?? null,
]);
// For port-specific variables, ALSO create port-specific pairs
// If template variable has port, create both URL and FQDN with port suffix
if ($parsed['has_port'] && $port) {
$fqdnPortKey = "SERVICE_FQDN_{$serviceName}_{$port}";
$resource->environment_variables()->updateOrCreate([
'key' => "SERVICE_FQDN_{$serviceName}_{$port}",
'key' => $fqdnPortKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdnValueForEnvWithPort,
'is_preview' => false,
'comment' => $envComments[$fqdnPortKey] ?? null,
]);
$urlPortKey = "SERVICE_URL_{$serviceName}_{$port}";
$resource->environment_variables()->updateOrCreate([
'key' => "SERVICE_URL_{$serviceName}_{$port}",
'key' => $urlPortKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $urlWithPort,
'is_preview' => false,
'comment' => $envComments[$urlPortKey] ?? null,
]);
}
}
}
$allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments);
if ($magicEnvironments->count() > 0) {
foreach ($magicEnvironments as $key => $value) {
$key = str($key);
foreach ($magicEnvironments as $magicKey => $value) {
$originalMagicKey = $magicKey; // Preserve original key for comment lookup
$key = str($magicKey);
$value = replaceVariables($value);
$command = parseCommandFromMagicEnvVariable($key);
if ($command->value() === 'FQDN') {
@@ -1762,6 +1891,8 @@ function serviceParser(Service $resource): Collection
$serviceExists->fqdn = $url;
$serviceExists->save();
}
// Create FQDN variable (use firstOrCreate to avoid overwriting values
// already set by direct template declarations or updateCompose)
$resource->environment_variables()->firstOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
@@ -1769,11 +1900,25 @@ function serviceParser(Service $resource): Collection
], [
'value' => $fqdn,
'is_preview' => false,
'comment' => $envComments[$originalMagicKey] ?? null,
]);
// Also create the paired SERVICE_URL_* variable
$urlKey = 'SERVICE_URL_'.strtoupper($fqdnFor);
$resource->environment_variables()->firstOrCreate([
'key' => $urlKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $url,
'is_preview' => false,
'comment' => $envComments[$urlKey] ?? null,
]);
} elseif ($command->value() === 'URL') {
$urlFor = $key->after('SERVICE_URL_')->lower()->value();
$url = generateUrl(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid");
$fqdn = generateFqdn(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid", parserVersion: $resource->compose_parsing_version);
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
// Also check if a port-suffixed version exists (e.g., SERVICE_URL_DASHBOARD_6791)
@@ -1790,6 +1935,8 @@ function serviceParser(Service $resource): Collection
$serviceExists->fqdn = $url;
$serviceExists->save();
}
// Create URL variable (use firstOrCreate to avoid overwriting values
// already set by direct template declarations or updateCompose)
$resource->environment_variables()->firstOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
@@ -1797,6 +1944,19 @@ function serviceParser(Service $resource): Collection
], [
'value' => $url,
'is_preview' => false,
'comment' => $envComments[$originalMagicKey] ?? null,
]);
// Also create the paired SERVICE_FQDN_* variable
$fqdnKey = 'SERVICE_FQDN_'.strtoupper($urlFor);
$resource->environment_variables()->firstOrCreate([
'key' => $fqdnKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
'is_preview' => false,
'comment' => $envComments[$fqdnKey] ?? null,
]);
} else {
@@ -1808,6 +1968,7 @@ function serviceParser(Service $resource): Collection
], [
'value' => $value,
'is_preview' => false,
'comment' => $envComments[$originalMagicKey] ?? null,
]);
}
}
@@ -1964,8 +2125,8 @@ function serviceParser(Service $resource): Collection
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceValue, 'volume source');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
@@ -1975,8 +2136,8 @@ function serviceParser(Service $resource): Collection
if ($target !== null && ! empty($target->value())) {
try {
validateShellSafePath($target->value(), 'volume target');
} catch (\Exception $e) {
throw new \Exception(
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
@@ -2163,18 +2324,20 @@ function serviceParser(Service $resource): Collection
return ! str($value)->startsWith('SERVICE_');
});
foreach ($normalEnvironments as $key => $value) {
$originalKey = $key; // Preserve original key for comment lookup
$key = str($key);
$value = str($value);
$originalValue = $value;
$parsedValue = replaceVariables($value);
if ($parsedValue->startsWith('SERVICE_')) {
$resource->environment_variables()->firstOrCreate([
$resource->environment_variables()->updateOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'comment' => $envComments[$originalKey] ?? null,
]);
continue;
@@ -2183,65 +2346,170 @@ function serviceParser(Service $resource): Collection
continue;
}
if ($key->value() === $parsedValue->value()) {
$value = null;
// Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL})
// Ensure the variable exists in DB for .env generation and UI display
$resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'comment' => $envComments[$originalKey] ?? null,
]);
// Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time.
// Do NOT replace with DB value: if user updates env var without re-parsing compose,
// a stale resolved value in environment: would override the correct .env value.
} else {
if ($value->startsWith('$')) {
$isRequired = false;
if ($value->contains(':-')) {
$value = replaceVariables($value);
$key = $value->before(':');
$value = $value->after(':-');
} elseif ($value->contains('-')) {
$value = replaceVariables($value);
$key = $value->before('-');
$value = $value->after('-');
} elseif ($value->contains(':?')) {
$value = replaceVariables($value);
// Extract variable content between ${...} using balanced brace matching
$result = extractBalancedBraceContent($value->value(), 0);
$key = $value->before(':');
$value = $value->after(':?');
$isRequired = true;
} elseif ($value->contains('?')) {
$value = replaceVariables($value);
if ($result !== null) {
$content = $result['content'];
$split = splitOnOperatorOutsideNested($content);
$key = $value->before('?');
$value = $value->after('?');
$isRequired = true;
}
if ($originalValue->value() === $value->value()) {
// This means the variable does not have a default value, so it needs to be created in Coolify
$parsedKeyValue = replaceVariables($value);
if ($split !== null) {
// Has default value syntax (:-, -, :?, or ?)
$varName = $split['variable'];
$operator = $split['operator'];
$defaultValue = $split['default'];
$isRequired = str_contains($operator, '?');
// Create the primary variable with its default (only if it doesn't exist)
// Use firstOrCreate instead of updateOrCreate to avoid overwriting user edits
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $varName,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $defaultValue,
'is_preview' => false,
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
// Add the variable to the environment so it will be shown in the deployable compose file
$environment[$varName] = $envVar->value;
// Recursively process nested variables in default value
if (str_contains($defaultValue, '${')) {
// Extract and create nested variables
$searchPos = 0;
$nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
while ($nestedResult !== null) {
$nestedContent = $nestedResult['content'];
$nestedSplit = splitOnOperatorOutsideNested($nestedContent);
// Determine the nested variable name
$nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent;
// Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system
$isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_');
if (! $isMagicVariable) {
if ($nestedSplit !== null) {
// Create nested variable with its default (only if it doesn't exist)
$nestedEnvVar = $resource->environment_variables()->firstOrCreate([
'key' => $nestedSplit['variable'],
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $nestedSplit['default'],
'is_preview' => false,
]);
// Add nested variable to environment
$environment[$nestedSplit['variable']] = $nestedEnvVar->value;
} else {
// Simple nested variable without default (only if it doesn't exist)
$nestedEnvVar = $resource->environment_variables()->firstOrCreate([
'key' => $nestedContent,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
]);
// Add nested variable to environment
$environment[$nestedContent] = $nestedEnvVar->value;
}
}
// Look for more nested variables
$searchPos = $nestedResult['end'] + 1;
if ($searchPos >= strlen($defaultValue)) {
break;
}
$nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
}
}
} else {
// Simple variable reference without default
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $content,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
// Add the variable to the environment using the saved DB value
$environment[$content] = $envVar->value;
}
} else {
// Fallback to old behavior for malformed input (backward compatibility)
if ($value->contains(':-')) {
$value = replaceVariables($value);
$key = $value->before(':');
$value = $value->after(':-');
} elseif ($value->contains('-')) {
$value = replaceVariables($value);
$key = $value->before('-');
$value = $value->after('-');
} elseif ($value->contains(':?')) {
$value = replaceVariables($value);
$key = $value->before(':');
$value = $value->after(':?');
$isRequired = true;
} elseif ($value->contains('?')) {
$value = replaceVariables($value);
$key = $value->before('?');
$value = $value->after('?');
$isRequired = true;
}
if ($originalValue->value() === $value->value()) {
// This means the variable does not have a default value
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
$parsedKeyValue = replaceVariables($value);
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $parsedKeyValue,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
// Add the variable to the environment using the saved DB value
$environment[$parsedKeyValue->value()] = $envVar->value;
continue;
}
// Variable with a default value from compose — use firstOrCreate to preserve user edits
$resource->environment_variables()->firstOrCreate([
'key' => $parsedKeyValue,
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
// Add the variable to the environment so it will be shown in the deployable compose file
$environment[$parsedKeyValue->value()] = $value;
continue;
}
$resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'is_required' => $isRequired,
]);
}
}
}
@@ -2307,6 +2575,13 @@ function serviceParser(Service $resource): Collection
// Otherwise keep empty string as-is
}
// Resolve shared variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}}
// Without this, literal {{...}} strings end up in the compose environment: section,
// which takes precedence over the resolved values in the .env file (env_file:)
if (is_string($value) && str_contains($value, '{{')) {
$value = resolveSharedEnvironmentVariables($value, $resource);
}
return $value;
});
}
@@ -2471,7 +2746,7 @@ function serviceParser(Service $resource): Collection
}
}
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
} catch (\Exception $e) {
} catch (Exception $e) {
// If parsing fails, keep the original docker_compose_raw unchanged
ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage());
}
+22 -10
View File
@@ -4,6 +4,7 @@ use App\Actions\Proxy\SaveProxyConfiguration;
use App\Enums\ProxyTypes;
use App\Models\Application;
use App\Models\Server;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Yaml\Yaml;
/**
@@ -108,18 +109,20 @@ function connectProxyToNetworks(Server $server)
['networks' => $networks] = collectDockerNetworksByServer($server);
if ($server->isSwarm()) {
$commands = $networks->map(function ($network) {
$safe = escapeshellarg($network);
return [
"docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --driver overlay --attachable $network >/dev/null",
"docker network connect $network coolify-proxy >/dev/null 2>&1 || true",
"echo 'Successfully connected coolify-proxy to $network network.'",
"docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --driver overlay --attachable {$safe} >/dev/null",
"docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true",
"echo 'Successfully connected coolify-proxy to {$safe} network.'",
];
});
} else {
$commands = $networks->map(function ($network) {
$safe = escapeshellarg($network);
return [
"docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --attachable $network >/dev/null",
"docker network connect $network coolify-proxy >/dev/null 2>&1 || true",
"echo 'Successfully connected coolify-proxy to $network network.'",
"docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --attachable {$safe} >/dev/null",
"docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true",
"echo 'Successfully connected coolify-proxy to {$safe} network.'",
];
});
}
@@ -140,16 +143,18 @@ function ensureProxyNetworksExist(Server $server)
if ($server->isSwarm()) {
$commands = $networks->map(function ($network) {
$safe = escapeshellarg($network);
return [
"echo 'Ensuring network $network exists...'",
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network",
"echo 'Ensuring network {$safe} exists...'",
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable {$safe}",
];
});
} else {
$commands = $networks->map(function ($network) {
$safe = escapeshellarg($network);
return [
"echo 'Ensuring network $network exists...'",
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network",
"echo 'Ensuring network {$safe} exists...'",
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable {$safe}",
];
});
}
@@ -215,6 +220,13 @@ function extractCustomProxyCommands(Server $server, string $existing_config): ar
}
function generateDefaultProxyConfiguration(Server $server, array $custom_commands = [])
{
Log::info('Generating default proxy configuration', [
'server_id' => $server->id,
'server_name' => $server->name,
'custom_commands_count' => count($custom_commands),
'caller' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[1]['class'] ?? 'unknown',
]);
$proxy_path = $server->proxyPath();
$proxy_type = $server->proxyType();
+43 -25
View File
@@ -1,9 +1,10 @@
<?php
use App\Actions\CoolifyTask\PrepareCoolifyTask;
use App\Data\CoolifyTaskArgs;
use App\Enums\ActivityTypes;
use App\Enums\ProcessStatus;
use App\Helpers\SshMultiplexingHelper;
use App\Helpers\SshRetryHandler;
use App\Jobs\CoolifyTask;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\PrivateKey;
@@ -38,29 +39,46 @@ function remote_process(
if (Auth::check()) {
$teams = Auth::user()->teams->pluck('id');
if (! $teams->contains($server->team_id) && ! $teams->contains(0)) {
throw new \Exception('User is not part of the team that owns this server');
throw new Exception('User is not part of the team that owns this server');
}
}
SshMultiplexingHelper::ensureMultiplexedConnection($server);
return resolve(PrepareCoolifyTask::class, [
'remoteProcessArgs' => new CoolifyTaskArgs(
server_uuid: $server->uuid,
command: $command_string,
type: $type,
type_uuid: $type_uuid,
model: $model,
ignore_errors: $ignore_errors,
call_event_on_finish: $callEventOnFinish,
call_event_data: $callEventData,
),
])();
$properties = [
'server_uuid' => $server->uuid,
'command' => $command_string,
'type' => $type,
'type_uuid' => $type_uuid,
'status' => ProcessStatus::QUEUED->value,
'team_id' => $server->team_id,
];
$activityLog = activity()
->withProperties($properties)
->event($type);
if ($model) {
$activityLog->performedOn($model);
}
$activity = $activityLog->log('[]');
dispatch(new CoolifyTask(
activity: $activity,
ignore_errors: $ignore_errors,
call_event_on_finish: $callEventOnFinish,
call_event_data: $callEventData,
));
$activity->refresh();
return $activity;
}
function instant_scp(string $source, string $dest, Server $server, $throwError = true)
{
return \App\Helpers\SshRetryHandler::retry(
return SshRetryHandler::retry(
function () use ($source, $dest, $server) {
$scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest);
$process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
@@ -92,7 +110,7 @@ function instant_remote_process_with_timeout(Collection|array $command, Server $
}
$command_string = implode("\n", $command);
return \App\Helpers\SshRetryHandler::retry(
return SshRetryHandler::retry(
function () use ($server, $command_string) {
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
$process = Process::timeout(30)->run($sshCommand);
@@ -128,7 +146,7 @@ function instant_remote_process(Collection|array $command, Server $server, bool
$command_string = implode("\n", $command);
$effectiveTimeout = $timeout ?? config('constants.ssh.command_timeout');
return \App\Helpers\SshRetryHandler::retry(
return SshRetryHandler::retry(
function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) {
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing);
$process = Process::timeout($effectiveTimeout)->run($sshCommand);
@@ -170,9 +188,9 @@ function excludeCertainErrors(string $errorOutput, ?int $exitCode = null)
if ($ignored) {
// TODO: Create new exception and disable in sentry
throw new \RuntimeException($errorMessage, $exitCode);
throw new RuntimeException($errorMessage, $exitCode);
}
throw new \RuntimeException($errorMessage, $exitCode);
throw new RuntimeException($errorMessage, $exitCode);
}
function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null, bool $includeAll = false): Collection
@@ -194,7 +212,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
associative: true,
flags: JSON_THROW_ON_ERROR
);
} catch (\JsonException $e) {
} catch (JsonException $e) {
// If JSON decoding fails, try to clean up the logs and retry
try {
// Ensure valid UTF-8 encoding
@@ -204,7 +222,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
associative: true,
flags: JSON_THROW_ON_ERROR
);
} catch (\JsonException $e) {
} catch (JsonException $e) {
// If it still fails, return empty collection to prevent crashes
return collect([]);
}
@@ -275,9 +293,9 @@ function remove_iip($text)
// ANSI color codes
$text = preg_replace('/\x1b\[[0-9;]*m/', '', $text);
// Generic URLs with passwords (covers database URLs, ftp, amqp, ssh, etc.)
// Generic URLs with passwords (covers database URLs, ftp, amqp, ssh, git basic auth, etc.)
// (protocol://user:password@host → protocol://user:<REDACTED>@host)
$text = preg_replace('/((?:postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:]+:)[^@]+(@)/i', '$1'.REDACTED.'$2', $text);
$text = preg_replace('/((?:https?|postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:]+:)[^@]+(@)/i', '$1'.REDACTED.'$2', $text);
// Email addresses
$text = preg_replace('/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', REDACTED, $text);
@@ -353,7 +371,7 @@ function checkRequiredCommands(Server $server)
}
try {
instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'apt update && apt install -y {$command}'"], $server);
} catch (\Throwable) {
} catch (Throwable) {
break;
}
$commandFound = instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'command -v {$command}'"], $server, false);
+115 -1
View File
@@ -17,9 +17,123 @@ function collectRegex(string $name)
{
return "/{$name}\w+/";
}
/**
* Extract content between balanced braces, handling nested braces properly.
*
* @param string $str The string to search
* @param int $startPos Position to start searching from
* @return array|null Array with 'content', 'start', and 'end' keys, or null if no balanced braces found
*/
function extractBalancedBraceContent(string $str, int $startPos = 0): ?array
{
// Find opening brace
if ($startPos >= strlen($str)) {
return null;
}
$openPos = strpos($str, '{', $startPos);
if ($openPos === false) {
return null;
}
// Track depth to find matching closing brace
$depth = 1;
$pos = $openPos + 1;
$len = strlen($str);
while ($pos < $len && $depth > 0) {
if ($str[$pos] === '{') {
$depth++;
} elseif ($str[$pos] === '}') {
$depth--;
}
$pos++;
}
if ($depth !== 0) {
// Unbalanced braces
return null;
}
return [
'content' => substr($str, $openPos + 1, $pos - $openPos - 2),
'start' => $openPos,
'end' => $pos - 1,
];
}
/**
* Split variable expression on operators (:-, -, :?, ?) while respecting nested braces.
*
* @param string $content The content to split (without outer ${...})
* @return array|null Array with 'variable', 'operator', and 'default' keys, or null if no operator found
*/
function splitOnOperatorOutsideNested(string $content): ?array
{
$operators = [':-', '-', ':?', '?'];
$depth = 0;
$len = strlen($content);
for ($i = 0; $i < $len; $i++) {
if ($content[$i] === '{') {
$depth++;
} elseif ($content[$i] === '}') {
$depth--;
} elseif ($depth === 0) {
// Check for operators only at depth 0 (outside nested braces)
foreach ($operators as $op) {
if (substr($content, $i, strlen($op)) === $op) {
return [
'variable' => substr($content, 0, $i),
'operator' => $op,
'default' => substr($content, $i + strlen($op)),
];
}
}
}
}
return null;
}
function replaceVariables(string $variable): Stringable
{
return str($variable)->before('}')->replaceFirst('$', '')->replaceFirst('{', '');
// Handle ${VAR} syntax with proper brace matching
$str = str($variable);
// Handle ${VAR} format
if ($str->startsWith('${')) {
$result = extractBalancedBraceContent($variable, 0);
if ($result !== null) {
return str($result['content']);
}
// Fallback to old behavior for malformed input
return $str->before('}')->replaceFirst('$', '')->replaceFirst('{', '');
}
// Handle {VAR} format (from regex capture group without $)
if ($str->startsWith('{') && $str->endsWith('}')) {
return str(substr($variable, 1, -1));
}
// Handle {VAR format (from regex capture group, may be truncated)
if ($str->startsWith('{')) {
$result = extractBalancedBraceContent('$'.$variable, 0);
if ($result !== null) {
return str($result['content']);
}
// Fallback: remove { and get content before }
return $str->replaceFirst('{', '')->before('}');
}
// Handle bare $VAR format (no braces)
if ($str->startsWith('$')) {
return $str->replaceFirst('$', '');
}
return $str;
}
function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Application $oneService, bool $isInit = false)
File diff suppressed because it is too large. Load diff
+2
View File
@@ -77,6 +77,7 @@ function allowedPathsForUnsubscribedAccounts()
'login',
'logout',
'force-password-reset',
'two-factor-challenge',
'livewire/update',
'admin',
];
@@ -95,6 +96,7 @@ function allowedPathsForInvalidAccounts()
'logout',
'verify',
'force-password-reset',
'two-factor-challenge',
'livewire/update',
];
}