mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 02:14:36 +00:00
Merge remote-tracking branch 'origin/next' into pr-7764-shadow/add-shared-server-env
This commit is contained in:
commit
cb97a18a78
767 files changed
+61894
-15664
No files matched your search
+33
-18
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
+659
-60
File diff suppressed because it is too large.
Load diff
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user