Improve variable initialization consistency in convertDockerRunToCompose() function to match established patterns used for --gpus and --hostname. Changes: - Add explicit $value = null initialization in --entrypoint block - Simplify conditional check from isset($value) to $value check - Maintain semantic equivalence with zero behavior changes This refactoring eliminates potential undefined variable warnings and improves code maintainability by following the defensive pattern used elsewhere in the file. Also fixes namespace for RestoreDatabase command from App\Console\Commands to App\Console\Commands\Cloud to match file location and prevent class redeclaration errors. Tests: All 20 tests in DockerCustomCommandsTest pass (25 assertions)
1378 lines
57 KiB
PHP
1378 lines
57 KiB
PHP
<?php
|
|
|
|
use App\Enums\ProxyTypes;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationPreview;
|
|
use App\Models\Server;
|
|
use App\Models\ServiceApplication;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
use Spatie\Url\Url;
|
|
use Symfony\Component\Yaml\Yaml;
|
|
use Visus\Cuid2\Cuid2;
|
|
|
|
function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection
|
|
{
|
|
$containers = collect([]);
|
|
if (! $server->isSwarm()) {
|
|
$containers = instant_remote_process(["docker ps -a --filter='label=coolify.applicationId={$id}' --format '{{json .}}' "], $server);
|
|
$containers = format_docker_command_output_to_json($containers);
|
|
|
|
$containers = $containers->map(function ($container) use ($pullRequestId, $includePullrequests) {
|
|
$labels = data_get($container, 'Labels');
|
|
$containerName = data_get($container, 'Names');
|
|
$hasPrLabel = str($labels)->contains('coolify.pullRequestId=');
|
|
$prLabelValue = null;
|
|
|
|
if ($hasPrLabel) {
|
|
preg_match('/coolify\.pullRequestId=(\d+)/', $labels, $matches);
|
|
$prLabelValue = $matches[1] ?? null;
|
|
}
|
|
|
|
// Treat pullRequestId=0 or missing label as base deployment (convention: 0 = no PR)
|
|
$isBaseDeploy = ! $hasPrLabel || (int) $prLabelValue === 0;
|
|
|
|
// If we're looking for a specific PR and this is a base deployment, exclude it
|
|
if ($pullRequestId !== null && $pullRequestId !== 0 && $isBaseDeploy) {
|
|
return null;
|
|
}
|
|
|
|
// If this is a base deployment, include it when not filtering for PRs
|
|
if ($isBaseDeploy) {
|
|
return $container;
|
|
}
|
|
|
|
if ($includePullrequests) {
|
|
return $container;
|
|
}
|
|
if ($pullRequestId !== null && $pullRequestId !== 0 && str($labels)->contains("coolify.pullRequestId={$pullRequestId}")) {
|
|
return $container;
|
|
}
|
|
|
|
return null;
|
|
});
|
|
|
|
$filtered = $containers->filter();
|
|
|
|
return $filtered;
|
|
}
|
|
|
|
return $containers;
|
|
}
|
|
|
|
function getCurrentServiceContainerStatus(Server $server, int $id): Collection
|
|
{
|
|
$containers = collect([]);
|
|
if (! $server->isSwarm()) {
|
|
$containers = instant_remote_process(["docker ps -a --filter='label=coolify.serviceId={$id}' --format '{{json .}}' "], $server);
|
|
$containers = format_docker_command_output_to_json($containers);
|
|
|
|
return $containers->filter();
|
|
}
|
|
|
|
return $containers;
|
|
}
|
|
|
|
function format_docker_command_output_to_json($rawOutput): Collection
|
|
{
|
|
$outputLines = explode(PHP_EOL, $rawOutput);
|
|
if (count($outputLines) === 1) {
|
|
$outputLines = collect($outputLines[0]);
|
|
} else {
|
|
$outputLines = collect($outputLines);
|
|
}
|
|
|
|
try {
|
|
return $outputLines
|
|
->reject(fn ($line) => empty($line))
|
|
->map(fn ($outputLine) => json_decode($outputLine, true, flags: JSON_THROW_ON_ERROR));
|
|
} catch (\Throwable) {
|
|
return collect([]);
|
|
}
|
|
}
|
|
|
|
function format_docker_labels_to_json(string|array $rawOutput): Collection
|
|
{
|
|
if (is_array($rawOutput)) {
|
|
return collect($rawOutput);
|
|
}
|
|
$outputLines = explode(PHP_EOL, $rawOutput);
|
|
|
|
return collect($outputLines)
|
|
->reject(fn ($line) => empty($line))
|
|
->map(function ($outputLine) {
|
|
$outputArray = explode(',', $outputLine);
|
|
|
|
return collect($outputArray)
|
|
->map(function ($outputLine) {
|
|
return explode('=', $outputLine);
|
|
})
|
|
->mapWithKeys(function ($outputLine) {
|
|
return [$outputLine[0] => $outputLine[1]];
|
|
});
|
|
})[0];
|
|
}
|
|
|
|
function format_docker_envs_to_json($rawOutput)
|
|
{
|
|
try {
|
|
$outputLines = json_decode($rawOutput, true, flags: JSON_THROW_ON_ERROR);
|
|
|
|
return collect(data_get($outputLines[0], 'Config.Env', []))->mapWithKeys(function ($env) {
|
|
$env = explode('=', $env, 2);
|
|
|
|
return [$env[0] => $env[1]];
|
|
});
|
|
} catch (\Throwable) {
|
|
return collect([]);
|
|
}
|
|
}
|
|
function checkMinimumDockerEngineVersion($dockerVersion)
|
|
{
|
|
$majorDockerVersion = str($dockerVersion)->before('.')->value();
|
|
$requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.')->value();
|
|
if ($majorDockerVersion < $requiredDockerVersion) {
|
|
$dockerVersion = null;
|
|
}
|
|
|
|
return $dockerVersion;
|
|
}
|
|
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'";
|
|
}
|
|
|
|
function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false)
|
|
{
|
|
if ($server->isSwarm()) {
|
|
$container = instant_remote_process(["docker service ls --filter 'name={$container_id}' --format '{{json .}}' "], $server, $throwError);
|
|
} else {
|
|
$container = instant_remote_process(["docker inspect --format '{{json .}}' {$container_id}"], $server, $throwError);
|
|
}
|
|
if (! $container) {
|
|
return 'exited';
|
|
}
|
|
$container = format_docker_command_output_to_json($container);
|
|
if ($container->isEmpty()) {
|
|
return 'exited';
|
|
}
|
|
if ($all_data) {
|
|
return $container[0];
|
|
}
|
|
if ($server->isSwarm()) {
|
|
$replicas = data_get($container[0], 'Replicas');
|
|
$replicas = explode('/', $replicas);
|
|
$active = (int) $replicas[0];
|
|
$total = (int) $replicas[1];
|
|
if ($active === $total) {
|
|
return 'running';
|
|
} else {
|
|
return 'starting';
|
|
}
|
|
} else {
|
|
return data_get($container[0], 'State.Status', 'exited');
|
|
}
|
|
}
|
|
|
|
function generateApplicationContainerName(Application $application, $pull_request_id = 0)
|
|
{
|
|
// TODO: refactor generateApplicationContainerName, we do not need $application and $pull_request_id
|
|
|
|
$consistent_container_name = $application->settings->is_consistent_container_name_enabled;
|
|
$now = now()->format('Hisu');
|
|
if ($pull_request_id !== 0 && $pull_request_id !== null) {
|
|
return $application->uuid.'-pr-'.$pull_request_id;
|
|
} else {
|
|
if ($consistent_container_name) {
|
|
return $application->uuid;
|
|
}
|
|
|
|
return $application->uuid.'-'.$now;
|
|
}
|
|
}
|
|
function get_port_from_dockerfile($dockerfile): ?int
|
|
{
|
|
$dockerfile_array = explode("\n", $dockerfile);
|
|
$found_exposed_port = null;
|
|
foreach ($dockerfile_array as $line) {
|
|
$line_str = str($line)->trim();
|
|
if ($line_str->startsWith('EXPOSE')) {
|
|
$found_exposed_port = $line_str->replace('EXPOSE', '')->trim();
|
|
break;
|
|
}
|
|
}
|
|
if ($found_exposed_port) {
|
|
return (int) $found_exposed_port->value();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function defaultDatabaseLabels($database)
|
|
{
|
|
$labels = collect([]);
|
|
$labels->push('coolify.managed=true');
|
|
$labels->push('coolify.type=database');
|
|
$labels->push('coolify.databaseId='.$database->id);
|
|
$labels->push('coolify.resourceName='.Str::slug($database->name));
|
|
$labels->push('coolify.serviceName='.Str::slug($database->name));
|
|
$labels->push('coolify.projectName='.Str::slug($database->project()->name));
|
|
$labels->push('coolify.environmentName='.Str::slug($database->environment->name));
|
|
$labels->push('coolify.database.subType='.$database->type());
|
|
|
|
return $labels;
|
|
}
|
|
|
|
function defaultLabels($id, $name, string $projectName, string $resourceName, string $environment, $pull_request_id = 0, string $type = 'application', $subType = null, $subId = null, $subName = null)
|
|
{
|
|
$labels = collect([]);
|
|
$labels->push('coolify.managed=true');
|
|
$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.resourceName='.Str::slug($resourceName));
|
|
$labels->push('coolify.projectName='.Str::slug($projectName));
|
|
$labels->push('coolify.serviceName='.Str::slug($subName ?? $resourceName));
|
|
$labels->push('coolify.environmentName='.Str::slug($environment));
|
|
|
|
$labels->push('coolify.pullRequestId='.$pull_request_id);
|
|
if ($type === 'service') {
|
|
$subId && $labels->push('coolify.service.subId='.$subId);
|
|
$subType && $labels->push('coolify.service.subType='.$subType);
|
|
$subName && $labels->push('coolify.service.subName='.Str::slug($subName));
|
|
}
|
|
|
|
return $labels;
|
|
}
|
|
|
|
function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
|
|
{
|
|
if ($resource->getMorphClass() === \App\Models\ServiceApplication::class) {
|
|
$uuid = data_get($resource, 'uuid');
|
|
$server = data_get($resource, 'service.server');
|
|
$environment_variables = data_get($resource, 'service.environment_variables');
|
|
$type = $resource->serviceType();
|
|
} elseif ($resource->getMorphClass() === \App\Models\Application::class) {
|
|
$uuid = data_get($resource, 'uuid');
|
|
$server = data_get($resource, 'destination.server');
|
|
$environment_variables = data_get($resource, 'environment_variables');
|
|
$type = $resource->serviceType();
|
|
}
|
|
if (is_null($server) || is_null($type)) {
|
|
return collect([]);
|
|
}
|
|
$variables = collect($environment_variables);
|
|
$payload = collect([]);
|
|
switch ($type) {
|
|
case $type?->contains('minio'):
|
|
$MINIO_BROWSER_REDIRECT_URL = $variables->where('key', 'MINIO_BROWSER_REDIRECT_URL')->first();
|
|
$MINIO_SERVER_URL = $variables->where('key', 'MINIO_SERVER_URL')->first();
|
|
|
|
if (is_null($MINIO_BROWSER_REDIRECT_URL) || is_null($MINIO_SERVER_URL)) {
|
|
return collect([]);
|
|
}
|
|
|
|
if (str($MINIO_BROWSER_REDIRECT_URL->value ?? '')->isEmpty()) {
|
|
$MINIO_BROWSER_REDIRECT_URL->update([
|
|
'value' => generateUrl(server: $server, random: 'console-'.$uuid, forceHttps: true),
|
|
]);
|
|
}
|
|
if (str($MINIO_SERVER_URL->value ?? '')->isEmpty()) {
|
|
$MINIO_SERVER_URL->update([
|
|
'value' => generateUrl(server: $server, random: 'minio-'.$uuid, forceHttps: true),
|
|
]);
|
|
}
|
|
$payload = collect([
|
|
$MINIO_BROWSER_REDIRECT_URL->value.':9001',
|
|
$MINIO_SERVER_URL->value.':9000',
|
|
]);
|
|
break;
|
|
case $type?->contains('logto'):
|
|
$LOGTO_ENDPOINT = $variables->where('key', 'LOGTO_ENDPOINT')->first();
|
|
$LOGTO_ADMIN_ENDPOINT = $variables->where('key', 'LOGTO_ADMIN_ENDPOINT')->first();
|
|
|
|
if (is_null($LOGTO_ENDPOINT) || is_null($LOGTO_ADMIN_ENDPOINT)) {
|
|
return collect([]);
|
|
}
|
|
|
|
if (str($LOGTO_ENDPOINT->value ?? '')->isEmpty()) {
|
|
$LOGTO_ENDPOINT->update([
|
|
'value' => generateUrl(server: $server, random: 'logto-'.$uuid),
|
|
]);
|
|
}
|
|
if (str($LOGTO_ADMIN_ENDPOINT->value ?? '')->isEmpty()) {
|
|
$LOGTO_ADMIN_ENDPOINT->update([
|
|
'value' => generateUrl(server: $server, random: 'logto-admin-'.$uuid),
|
|
]);
|
|
}
|
|
$payload = collect([
|
|
$LOGTO_ENDPOINT->value.':3001',
|
|
$LOGTO_ADMIN_ENDPOINT->value.':3002',
|
|
]);
|
|
break;
|
|
}
|
|
|
|
return $payload;
|
|
}
|
|
|
|
function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null)
|
|
{
|
|
$labels = collect([]);
|
|
if ($serviceLabels) {
|
|
$labels->push("caddy_ingress_network={$uuid}");
|
|
} else {
|
|
$labels->push("caddy_ingress_network={$network}");
|
|
}
|
|
|
|
$is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null;
|
|
if ($is_http_basic_auth_enabled) {
|
|
$hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => 10]);
|
|
}
|
|
|
|
foreach ($domains as $loop => $domain) {
|
|
$url = Url::fromString($domain);
|
|
$host = $url->getHost();
|
|
$path = $url->getPath();
|
|
$host_without_www = str($host)->replace('www.', '');
|
|
$schema = $url->getScheme();
|
|
$port = $url->getPort();
|
|
$handle = 'handle_path';
|
|
if (! $is_stripprefix_enabled) {
|
|
$handle = 'handle';
|
|
}
|
|
if (is_null($port) && ! is_null($onlyPort)) {
|
|
$port = $onlyPort;
|
|
}
|
|
if (is_null($port) && $predefinedPort) {
|
|
$port = $predefinedPort;
|
|
}
|
|
$labels->push("caddy_{$loop}={$schema}://{$host}");
|
|
$labels->push("caddy_{$loop}.header=-Server");
|
|
$labels->push("caddy_{$loop}.try_files={path} /index.html /index.php");
|
|
|
|
if ($port) {
|
|
$labels->push("caddy_{$loop}.{$handle}.{$loop}_reverse_proxy={{upstreams $port}}");
|
|
} else {
|
|
$labels->push("caddy_{$loop}.{$handle}.{$loop}_reverse_proxy={{upstreams}}");
|
|
}
|
|
$labels->push("caddy_{$loop}.{$handle}={$path}*");
|
|
if ($is_gzip_enabled) {
|
|
$labels->push("caddy_{$loop}.encode=zstd gzip");
|
|
}
|
|
if ($redirect_direction === 'www' && ! str($host)->startsWith('www.')) {
|
|
$labels->push("caddy_{$loop}.redir={$schema}://www.{$host}{uri}");
|
|
}
|
|
if ($redirect_direction === 'non-www' && str($host)->startsWith('www.')) {
|
|
$labels->push("caddy_{$loop}.redir={$schema}://{$host_without_www}{uri}");
|
|
}
|
|
if ($is_http_basic_auth_enabled) {
|
|
$labels->push("caddy_{$loop}.basicauth.{$http_basic_auth_username}=\"{$hashedPassword}\"");
|
|
}
|
|
}
|
|
|
|
return $labels->sort();
|
|
}
|
|
|
|
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null)
|
|
{
|
|
$labels = collect([]);
|
|
$labels->push('traefik.enable=true');
|
|
if ($is_gzip_enabled) {
|
|
$labels->push('traefik.http.middlewares.gzip.compress=true');
|
|
}
|
|
$labels->push('traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https');
|
|
|
|
$is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null;
|
|
$http_basic_auth_label = "http-basic-auth-{$uuid}";
|
|
if ($is_http_basic_auth_enabled) {
|
|
$hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => 10]);
|
|
}
|
|
|
|
if ($is_http_basic_auth_enabled) {
|
|
$labels->push("traefik.http.middlewares.{$http_basic_auth_label}.basicauth.users={$http_basic_auth_username}:{$hashedPassword}");
|
|
}
|
|
|
|
$middlewares_from_labels = collect([]);
|
|
|
|
if ($serviceLabels) {
|
|
$middlewares_from_labels = $serviceLabels->map(function ($item) {
|
|
// Handle array values from YAML parsing (e.g., "traefik.enable: true" becomes an array)
|
|
if (is_array($item)) {
|
|
// Convert array to string format "key=value"
|
|
$key = collect($item)->keys()->first();
|
|
$value = collect($item)->values()->first();
|
|
$item = "$key=$value";
|
|
}
|
|
if (! is_string($item)) {
|
|
return null;
|
|
}
|
|
if (preg_match('/traefik\.http\.middlewares\.(.*?)(\.|$)/', $item, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
if (preg_match('/coolify\.traefik\.middlewares=(.*)/', $item, $matches)) {
|
|
return explode(',', $matches[1]);
|
|
}
|
|
|
|
return null;
|
|
})->flatten()
|
|
->filter()
|
|
->unique();
|
|
}
|
|
foreach ($domains as $loop => $domain) {
|
|
try {
|
|
if ($generate_unique_uuid) {
|
|
$uuid = new Cuid2;
|
|
}
|
|
|
|
$url = Url::fromString($domain);
|
|
$host = $url->getHost();
|
|
$path = $url->getPath();
|
|
$schema = $url->getScheme();
|
|
$port = $url->getPort();
|
|
if (is_null($port) && ! is_null($onlyPort)) {
|
|
$port = $onlyPort;
|
|
}
|
|
$http_label = "http-{$loop}-{$uuid}";
|
|
$https_label = "https-{$loop}-{$uuid}";
|
|
if ($service_name) {
|
|
$http_label = "http-{$loop}-{$uuid}-{$service_name}";
|
|
$https_label = "https-{$loop}-{$uuid}-{$service_name}";
|
|
}
|
|
if (str($image)->contains('ghost')) {
|
|
$labels->push("traefik.http.middlewares.redir-ghost-{$uuid}.redirectregex.regex=^{$path}/(.*)");
|
|
$labels->push("traefik.http.middlewares.redir-ghost-{$uuid}.redirectregex.replacement=/$1");
|
|
$labels->push("caddy_{$loop}.handle_path.{$loop}_redir-ghost-{$uuid}.handler=rewrite");
|
|
$labels->push("caddy_{$loop}.handle_path.{$loop}_redir-ghost-{$uuid}.rewrite.regexp=^{$path}/(.*)");
|
|
$labels->push("caddy_{$loop}.handle_path.{$loop}_redir-ghost-{$uuid}.rewrite.replacement=/$1");
|
|
}
|
|
|
|
$to_www_name = "{$loop}-{$uuid}-to-www";
|
|
$to_non_www_name = "{$loop}-{$uuid}-to-non-www";
|
|
$redirect_to_non_www = [
|
|
"traefik.http.middlewares.{$to_non_www_name}.redirectregex.regex=^(http|https)://www\.(.+)",
|
|
"traefik.http.middlewares.{$to_non_www_name}.redirectregex.replacement=\${1}://\${2}",
|
|
"traefik.http.middlewares.{$to_non_www_name}.redirectregex.permanent=false",
|
|
];
|
|
$redirect_to_www = [
|
|
"traefik.http.middlewares.{$to_www_name}.redirectregex.regex=^(http|https)://(?:www\.)?(.+)",
|
|
"traefik.http.middlewares.{$to_www_name}.redirectregex.replacement=\${1}://www.\${2}",
|
|
"traefik.http.middlewares.{$to_www_name}.redirectregex.permanent=false",
|
|
];
|
|
if ($schema === 'https') {
|
|
// Set labels for https
|
|
$labels->push("traefik.http.routers.{$https_label}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)");
|
|
$labels->push("traefik.http.routers.{$https_label}.entryPoints=https");
|
|
if ($port) {
|
|
$labels->push("traefik.http.routers.{$https_label}.service={$https_label}");
|
|
$labels->push("traefik.http.services.{$https_label}.loadbalancer.server.port=$port");
|
|
}
|
|
if ($path !== '/') {
|
|
// Middleware handling
|
|
$middlewares = collect([]);
|
|
if ($is_stripprefix_enabled && ! str($image)->contains('ghost')) {
|
|
$labels->push("traefik.http.middlewares.{$https_label}-stripprefix.stripprefix.prefixes={$path}");
|
|
$middlewares->push("{$https_label}-stripprefix");
|
|
}
|
|
if ($is_gzip_enabled) {
|
|
$middlewares->push('gzip');
|
|
}
|
|
if (str($image)->contains('ghost')) {
|
|
$middlewares->push("redir-ghost-{$uuid}");
|
|
}
|
|
if ($redirect_direction === 'non-www' && str($host)->startsWith('www.')) {
|
|
$labels = $labels->merge($redirect_to_non_www);
|
|
$middlewares->push($to_non_www_name);
|
|
}
|
|
if ($redirect_direction === 'www' && ! str($host)->startsWith('www.')) {
|
|
$labels = $labels->merge($redirect_to_www);
|
|
$middlewares->push($to_www_name);
|
|
}
|
|
if ($is_http_basic_auth_enabled) {
|
|
$middlewares->push($http_basic_auth_label);
|
|
}
|
|
$middlewares_from_labels->each(function ($middleware_name) use ($middlewares) {
|
|
$middlewares->push($middleware_name);
|
|
});
|
|
if ($middlewares->isNotEmpty()) {
|
|
$middlewares = $middlewares->join(',');
|
|
$labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares}");
|
|
}
|
|
} else {
|
|
$middlewares = collect([]);
|
|
if ($is_gzip_enabled) {
|
|
$middlewares->push('gzip');
|
|
}
|
|
if (str($image)->contains('ghost')) {
|
|
$middlewares->push("redir-ghost-{$uuid}");
|
|
}
|
|
if ($redirect_direction === 'non-www' && str($host)->startsWith('www.')) {
|
|
$labels = $labels->merge($redirect_to_non_www);
|
|
$middlewares->push($to_non_www_name);
|
|
}
|
|
if ($redirect_direction === 'www' && ! str($host)->startsWith('www.')) {
|
|
$labels = $labels->merge($redirect_to_www);
|
|
$middlewares->push($to_www_name);
|
|
}
|
|
if ($is_http_basic_auth_enabled) {
|
|
$middlewares->push($http_basic_auth_label);
|
|
}
|
|
$middlewares_from_labels->each(function ($middleware_name) use ($middlewares) {
|
|
$middlewares->push($middleware_name);
|
|
});
|
|
if ($middlewares->isNotEmpty()) {
|
|
$middlewares = $middlewares->join(',');
|
|
$labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares}");
|
|
}
|
|
}
|
|
$labels->push("traefik.http.routers.{$https_label}.tls=true");
|
|
$labels->push("traefik.http.routers.{$https_label}.tls.certresolver=letsencrypt");
|
|
|
|
// Set labels for http (redirect to https)
|
|
$labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)");
|
|
$labels->push("traefik.http.routers.{$http_label}.entryPoints=http");
|
|
if ($port) {
|
|
$labels->push("traefik.http.services.{$http_label}.loadbalancer.server.port=$port");
|
|
$labels->push("traefik.http.routers.{$http_label}.service={$http_label}");
|
|
}
|
|
if ($is_force_https_enabled) {
|
|
$labels->push("traefik.http.routers.{$http_label}.middlewares=redirect-to-https");
|
|
}
|
|
} else {
|
|
// Set labels for http
|
|
$labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)");
|
|
$labels->push("traefik.http.routers.{$http_label}.entryPoints=http");
|
|
if ($port) {
|
|
$labels->push("traefik.http.services.{$http_label}.loadbalancer.server.port=$port");
|
|
$labels->push("traefik.http.routers.{$http_label}.service={$http_label}");
|
|
}
|
|
if ($path !== '/') {
|
|
$middlewares = collect([]);
|
|
if ($is_stripprefix_enabled && ! str($image)->contains('ghost')) {
|
|
$labels->push("traefik.http.middlewares.{$http_label}-stripprefix.stripprefix.prefixes={$path}");
|
|
$middlewares->push("{$http_label}-stripprefix");
|
|
}
|
|
if ($is_gzip_enabled) {
|
|
$middlewares->push('gzip');
|
|
}
|
|
if (str($image)->contains('ghost')) {
|
|
$middlewares->push("redir-ghost-{$uuid}");
|
|
}
|
|
if ($redirect_direction === 'non-www' && str($host)->startsWith('www.')) {
|
|
$labels = $labels->merge($redirect_to_non_www);
|
|
$middlewares->push($to_non_www_name);
|
|
}
|
|
if ($redirect_direction === 'www' && ! str($host)->startsWith('www.')) {
|
|
$labels = $labels->merge($redirect_to_www);
|
|
$middlewares->push($to_www_name);
|
|
}
|
|
if ($is_http_basic_auth_enabled) {
|
|
$middlewares->push($http_basic_auth_label);
|
|
}
|
|
$middlewares_from_labels->each(function ($middleware_name) use ($middlewares) {
|
|
$middlewares->push($middleware_name);
|
|
});
|
|
if ($middlewares->isNotEmpty()) {
|
|
$middlewares = $middlewares->join(',');
|
|
$labels->push("traefik.http.routers.{$http_label}.middlewares={$middlewares}");
|
|
}
|
|
} else {
|
|
$middlewares = collect([]);
|
|
if ($is_gzip_enabled) {
|
|
$middlewares->push('gzip');
|
|
}
|
|
if (str($image)->contains('ghost')) {
|
|
$middlewares->push("redir-ghost-{$uuid}");
|
|
}
|
|
if ($redirect_direction === 'non-www' && str($host)->startsWith('www.')) {
|
|
$labels = $labels->merge($redirect_to_non_www);
|
|
$middlewares->push($to_non_www_name);
|
|
}
|
|
if ($redirect_direction === 'www' && ! str($host)->startsWith('www.')) {
|
|
$labels = $labels->merge($redirect_to_www);
|
|
$middlewares->push($to_www_name);
|
|
}
|
|
if ($is_http_basic_auth_enabled) {
|
|
$middlewares->push($http_basic_auth_label);
|
|
}
|
|
$middlewares_from_labels->each(function ($middleware_name) use ($middlewares) {
|
|
$middlewares->push($middleware_name);
|
|
});
|
|
if ($middlewares->isNotEmpty()) {
|
|
$middlewares = $middlewares->join(',');
|
|
$labels->push("traefik.http.routers.{$http_label}.middlewares={$middlewares}");
|
|
}
|
|
}
|
|
}
|
|
} catch (\Throwable) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return $labels->sort();
|
|
}
|
|
function generateLabelsApplication(Application $application, ?ApplicationPreview $preview = null): array
|
|
{
|
|
$ports = $application->settings->is_static ? [80] : $application->ports_exposes_array;
|
|
$onlyPort = null;
|
|
if (count($ports) > 0) {
|
|
$onlyPort = $ports[0];
|
|
}
|
|
$pull_request_id = data_get($preview, 'pull_request_id', 0);
|
|
$appUuid = $application->uuid;
|
|
if ($pull_request_id !== 0) {
|
|
$appUuid = $appUuid.'-pr-'.$pull_request_id;
|
|
}
|
|
$labels = collect([]);
|
|
if ($pull_request_id === 0) {
|
|
if ($application->fqdn) {
|
|
$domains = str(data_get($application, 'fqdn'))->explode(',');
|
|
$shouldGenerateLabelsExactly = $application->destination->server->settings->generate_exact_labels;
|
|
if ($shouldGenerateLabelsExactly) {
|
|
switch ($application->destination->server->proxyType()) {
|
|
case ProxyTypes::TRAEFIK->value:
|
|
$labels = $labels->merge(fqdnLabelsForTraefik(
|
|
uuid: $appUuid,
|
|
domains: $domains,
|
|
onlyPort: $onlyPort,
|
|
is_force_https_enabled: $application->isForceHttpsEnabled(),
|
|
is_gzip_enabled: $application->isGzipEnabled(),
|
|
is_stripprefix_enabled: $application->isStripprefixEnabled(),
|
|
redirect_direction: $application->redirect,
|
|
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
|
http_basic_auth_username: $application->http_basic_auth_username,
|
|
http_basic_auth_password: $application->http_basic_auth_password,
|
|
));
|
|
break;
|
|
case ProxyTypes::CADDY->value:
|
|
$labels = $labels->merge(fqdnLabelsForCaddy(
|
|
network: $application->destination->network,
|
|
uuid: $appUuid,
|
|
domains: $domains,
|
|
onlyPort: $onlyPort,
|
|
is_force_https_enabled: $application->isForceHttpsEnabled(),
|
|
is_gzip_enabled: $application->isGzipEnabled(),
|
|
is_stripprefix_enabled: $application->isStripprefixEnabled(),
|
|
redirect_direction: $application->redirect,
|
|
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
|
http_basic_auth_username: $application->http_basic_auth_username,
|
|
http_basic_auth_password: $application->http_basic_auth_password,
|
|
));
|
|
break;
|
|
}
|
|
} else {
|
|
$labels = $labels->merge(fqdnLabelsForTraefik(
|
|
uuid: $appUuid,
|
|
domains: $domains,
|
|
onlyPort: $onlyPort,
|
|
is_force_https_enabled: $application->isForceHttpsEnabled(),
|
|
is_gzip_enabled: $application->isGzipEnabled(),
|
|
is_stripprefix_enabled: $application->isStripprefixEnabled(),
|
|
redirect_direction: $application->redirect,
|
|
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
|
http_basic_auth_username: $application->http_basic_auth_username,
|
|
http_basic_auth_password: $application->http_basic_auth_password,
|
|
));
|
|
$labels = $labels->merge(fqdnLabelsForCaddy(
|
|
network: $application->destination->network,
|
|
uuid: $appUuid,
|
|
domains: $domains,
|
|
onlyPort: $onlyPort,
|
|
is_force_https_enabled: $application->isForceHttpsEnabled(),
|
|
is_gzip_enabled: $application->isGzipEnabled(),
|
|
is_stripprefix_enabled: $application->isStripprefixEnabled(),
|
|
redirect_direction: $application->redirect,
|
|
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
|
http_basic_auth_username: $application->http_basic_auth_username,
|
|
http_basic_auth_password: $application->http_basic_auth_password,
|
|
));
|
|
}
|
|
}
|
|
} else {
|
|
if (data_get($preview, 'fqdn')) {
|
|
$domains = str(data_get($preview, 'fqdn'))->explode(',');
|
|
} else {
|
|
$domains = collect([]);
|
|
}
|
|
$shouldGenerateLabelsExactly = $application->destination->server->settings->generate_exact_labels;
|
|
if ($shouldGenerateLabelsExactly) {
|
|
switch ($application->destination->server->proxyType()) {
|
|
case ProxyTypes::TRAEFIK->value:
|
|
$labels = $labels->merge(fqdnLabelsForTraefik(
|
|
uuid: $appUuid,
|
|
domains: $domains,
|
|
onlyPort: $onlyPort,
|
|
is_force_https_enabled: $application->isForceHttpsEnabled(),
|
|
is_gzip_enabled: $application->isGzipEnabled(),
|
|
is_stripprefix_enabled: $application->isStripprefixEnabled(),
|
|
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
|
http_basic_auth_username: $application->http_basic_auth_username,
|
|
http_basic_auth_password: $application->http_basic_auth_password,
|
|
));
|
|
break;
|
|
case ProxyTypes::CADDY->value:
|
|
$labels = $labels->merge(fqdnLabelsForCaddy(
|
|
network: $application->destination->network,
|
|
uuid: $appUuid,
|
|
domains: $domains,
|
|
onlyPort: $onlyPort,
|
|
is_force_https_enabled: $application->isForceHttpsEnabled(),
|
|
is_gzip_enabled: $application->isGzipEnabled(),
|
|
is_stripprefix_enabled: $application->isStripprefixEnabled(),
|
|
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
|
http_basic_auth_username: $application->http_basic_auth_username,
|
|
http_basic_auth_password: $application->http_basic_auth_password,
|
|
));
|
|
break;
|
|
}
|
|
} else {
|
|
$labels = $labels->merge(fqdnLabelsForTraefik(
|
|
uuid: $appUuid,
|
|
domains: $domains,
|
|
onlyPort: $onlyPort,
|
|
is_force_https_enabled: $application->isForceHttpsEnabled(),
|
|
is_gzip_enabled: $application->isGzipEnabled(),
|
|
is_stripprefix_enabled: $application->isStripprefixEnabled(),
|
|
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
|
http_basic_auth_username: $application->http_basic_auth_username,
|
|
http_basic_auth_password: $application->http_basic_auth_password,
|
|
));
|
|
$labels = $labels->merge(fqdnLabelsForCaddy(
|
|
network: $application->destination->network,
|
|
uuid: $appUuid,
|
|
domains: $domains,
|
|
onlyPort: $onlyPort,
|
|
is_force_https_enabled: $application->isForceHttpsEnabled(),
|
|
is_gzip_enabled: $application->isGzipEnabled(),
|
|
is_stripprefix_enabled: $application->isStripprefixEnabled(),
|
|
is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled,
|
|
http_basic_auth_username: $application->http_basic_auth_username,
|
|
http_basic_auth_password: $application->http_basic_auth_password,
|
|
));
|
|
}
|
|
}
|
|
|
|
return $labels->all();
|
|
}
|
|
|
|
function isDatabaseImage(?string $image = null, ?array $serviceConfig = null)
|
|
{
|
|
if (is_null($image)) {
|
|
return false;
|
|
}
|
|
|
|
$image = str($image);
|
|
if ($image->contains(':')) {
|
|
$image = str($image);
|
|
} else {
|
|
$image = str($image)->append(':latest');
|
|
}
|
|
$imageName = $image->before(':');
|
|
|
|
// First check if it's a known database image
|
|
$isKnownDatabase = false;
|
|
foreach (DATABASE_DOCKER_IMAGES as $database_docker_image) {
|
|
if (str($imageName)->contains($database_docker_image)) {
|
|
$isKnownDatabase = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If no database pattern found, it's definitely not a database
|
|
if (! $isKnownDatabase) {
|
|
return false;
|
|
}
|
|
|
|
// If we have service configuration, use additional context to make better decisions
|
|
if (! is_null($serviceConfig)) {
|
|
return isDatabaseImageWithContext($imageName, $serviceConfig);
|
|
}
|
|
|
|
// Fallback to original behavior for backward compatibility
|
|
return $isKnownDatabase;
|
|
}
|
|
|
|
function isDatabaseImageWithContext(string $imageName, array $serviceConfig): bool
|
|
{
|
|
// Known application images that contain database names but are not databases
|
|
$knownApplicationPatterns = [
|
|
// SuperTokens authentication
|
|
'supertokens/supertokens-mysql',
|
|
'supertokens/supertokens-postgresql',
|
|
'supertokens/supertokens-mongodb',
|
|
'registry.supertokens.io/supertokens/supertokens-mysql',
|
|
'registry.supertokens.io/supertokens/supertokens-postgresql',
|
|
'registry.supertokens.io/supertokens/supertokens-mongodb',
|
|
'registry.supertokens.io/supertokens',
|
|
|
|
// Analytics and BI tools
|
|
'metabase/metabase', // Uses databases but is not a database
|
|
'amancevice/superset', // Uses databases but is not a database
|
|
'nocodb/nocodb', // Uses databases but is not a database
|
|
'ghcr.io/umami-software/umami', // Web analytics with postgresql variant
|
|
|
|
// Secret management
|
|
'infisical/infisical', // Secret management with postgres variant
|
|
|
|
// Development tools
|
|
'postgrest/postgrest', // REST API for PostgreSQL
|
|
'supabase/postgres-meta', // PostgreSQL metadata API
|
|
'bluewaveuptime/uptime_redis', // Uptime monitoring with Redis
|
|
];
|
|
|
|
foreach ($knownApplicationPatterns as $pattern) {
|
|
if (str($imageName)->contains($pattern)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check for database-like ports (common database ports indicate it's likely a database)
|
|
$databasePorts = ['3306', '5432', '27017', '6379', '8086', '9200', '7687', '8123'];
|
|
$ports = data_get($serviceConfig, 'ports', []);
|
|
$hasStandardDbPort = false;
|
|
|
|
if (is_array($ports)) {
|
|
foreach ($ports as $port) {
|
|
$portStr = is_string($port) ? $port : (string) $port;
|
|
foreach ($databasePorts as $dbPort) {
|
|
if (str($portStr)->contains($dbPort)) {
|
|
$hasStandardDbPort = true;
|
|
break 2;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check environment variables for database-specific patterns
|
|
$environment = data_get($serviceConfig, 'environment', []);
|
|
$hasDbEnvVars = false;
|
|
$hasAppEnvVars = false;
|
|
|
|
if (is_array($environment)) {
|
|
foreach ($environment as $env) {
|
|
$envStr = is_string($env) ? $env : (string) $env;
|
|
$envUpper = strtoupper($envStr);
|
|
|
|
// Database-specific environment variables
|
|
if (str($envUpper)->contains(['MYSQL_ROOT_PASSWORD', 'POSTGRES_PASSWORD', 'MONGO_INITDB_ROOT_PASSWORD', 'REDIS_PASSWORD'])) {
|
|
$hasDbEnvVars = true;
|
|
}
|
|
|
|
// Application-specific environment variables
|
|
if (str($envUpper)->contains(['SERVICE_FQDN', 'API_KEYS', 'APP_', 'APPLICATION_'])) {
|
|
$hasAppEnvVars = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check healthcheck patterns
|
|
$healthcheck = data_get($serviceConfig, 'healthcheck.test', []);
|
|
$hasDbHealthcheck = false;
|
|
$hasAppHealthcheck = false;
|
|
|
|
if (is_array($healthcheck)) {
|
|
$healthcheckStr = implode(' ', $healthcheck);
|
|
} else {
|
|
$healthcheckStr = is_string($healthcheck) ? $healthcheck : '';
|
|
}
|
|
|
|
if (! empty($healthcheckStr)) {
|
|
$healthcheckUpper = strtoupper($healthcheckStr);
|
|
|
|
// Database-specific healthcheck patterns
|
|
if (str($healthcheckUpper)->contains(['PG_ISREADY', 'MYSQLADMIN PING', 'MONGO', 'REDIS-CLI PING'])) {
|
|
$hasDbHealthcheck = true;
|
|
}
|
|
|
|
// Application-specific healthcheck patterns (HTTP endpoints)
|
|
if (str($healthcheckUpper)->contains(['CURL', 'WGET', 'HTTP://', 'HTTPS://', '/HEALTH', '/API/', '/HELLO'])) {
|
|
$hasAppHealthcheck = true;
|
|
}
|
|
}
|
|
|
|
// Check if service depends on other database services
|
|
$dependsOn = data_get($serviceConfig, 'depends_on', []);
|
|
$dependsOnDatabases = false;
|
|
|
|
if (is_array($dependsOn)) {
|
|
foreach ($dependsOn as $serviceName => $config) {
|
|
$serviceNameStr = is_string($serviceName) ? $serviceName : (string) $serviceName;
|
|
if (str($serviceNameStr)->contains(['mysql', 'postgres', 'mongo', 'redis', 'mariadb'])) {
|
|
$dependsOnDatabases = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Decision logic:
|
|
// 1. If it has app-specific patterns and depends on databases, it's likely an application
|
|
if ($hasAppEnvVars && $dependsOnDatabases) {
|
|
return false;
|
|
}
|
|
|
|
// 2. If it has HTTP healthchecks, it's likely an application
|
|
if ($hasAppHealthcheck) {
|
|
return false;
|
|
}
|
|
|
|
// 3. If it has standard database ports AND database healthchecks, it's likely a database
|
|
if ($hasStandardDbPort && $hasDbHealthcheck) {
|
|
return true;
|
|
}
|
|
|
|
// 4. If it has database environment variables, it's likely a database
|
|
if ($hasDbEnvVars) {
|
|
return true;
|
|
}
|
|
|
|
// 5. Default: if it depends on databases but doesn't have database characteristics, it's an application
|
|
if ($dependsOnDatabases) {
|
|
return false;
|
|
}
|
|
|
|
// 6. Fallback: assume it's a database if we can't determine otherwise
|
|
return true;
|
|
}
|
|
|
|
function convertDockerRunToCompose(?string $custom_docker_run_options = null)
|
|
{
|
|
$options = [];
|
|
$compose_options = collect([]);
|
|
preg_match_all('/(--\w+(?:-\w+)*)(?:\s|=)?([^\s-]+)?/', $custom_docker_run_options, $matches, PREG_SET_ORDER);
|
|
$list_options = collect([
|
|
'--cap-add',
|
|
'--cap-drop',
|
|
'--security-opt',
|
|
'--sysctl',
|
|
'--ulimit',
|
|
'--device',
|
|
'--shm-size',
|
|
]);
|
|
$mapping = collect([
|
|
'--cap-add' => 'cap_add',
|
|
'--cap-drop' => 'cap_drop',
|
|
'--security-opt' => 'security_opt',
|
|
'--sysctl' => 'sysctls',
|
|
'--device' => 'devices',
|
|
'--init' => 'init',
|
|
'--ulimit' => 'ulimits',
|
|
'--privileged' => 'privileged',
|
|
'--ip' => 'ip',
|
|
'--shm-size' => 'shm_size',
|
|
'--gpus' => 'gpus',
|
|
'--hostname' => 'hostname',
|
|
'--entrypoint' => 'entrypoint',
|
|
]);
|
|
foreach ($matches as $match) {
|
|
$option = $match[1];
|
|
if ($option === '--gpus') {
|
|
$regexForParsingDeviceIds = '/device=([0-9A-Za-z-,]+)/';
|
|
preg_match($regexForParsingDeviceIds, $custom_docker_run_options, $device_matches);
|
|
$value = $device_matches[1] ?? 'all';
|
|
$options[$option][] = $value;
|
|
$options[$option] = array_unique($options[$option]);
|
|
}
|
|
if ($option === '--hostname') {
|
|
// Match --hostname=value or --hostname value
|
|
$regexForParsingHostname = '/--hostname(?:=|\s+)([^\s]+)/';
|
|
preg_match($regexForParsingHostname, $custom_docker_run_options, $hostname_matches);
|
|
$value = $hostname_matches[1] ?? null;
|
|
if ($value && ! empty(trim($value))) {
|
|
$options[$option][] = $value;
|
|
$options[$option] = array_unique($options[$option]);
|
|
}
|
|
}
|
|
if ($option === '--entrypoint') {
|
|
$value = null;
|
|
// Match --entrypoint=value or --entrypoint value
|
|
// Handle quoted strings with escaped quotes: --entrypoint "python -c \"print('hi')\""
|
|
// Pattern matches: double-quoted (with escapes), single-quoted (with escapes), or unquoted values
|
|
if (preg_match(
|
|
'/--entrypoint(?:=|\s+)(?<raw>"(?:\\\\.|[^"])*"|\'(?:\\\\.|[^\'])*\'|[^\s]+)/',
|
|
$custom_docker_run_options,
|
|
$entrypoint_matches
|
|
)) {
|
|
$rawValue = $entrypoint_matches['raw'];
|
|
// Handle double-quoted strings: strip quotes and unescape special characters
|
|
if (str_starts_with($rawValue, '"') && str_ends_with($rawValue, '"')) {
|
|
$inner = substr($rawValue, 1, -1);
|
|
// Unescape backslash sequences: \" \$ \` \\
|
|
$value = preg_replace('/\\\\(["$`\\\\])/', '$1', $inner);
|
|
} elseif (str_starts_with($rawValue, "'") && str_ends_with($rawValue, "'")) {
|
|
// Handle single-quoted strings: just strip quotes (no unescaping per shell rules)
|
|
$value = substr($rawValue, 1, -1);
|
|
} else {
|
|
// Handle unquoted values
|
|
$value = $rawValue;
|
|
}
|
|
}
|
|
|
|
if ($value && trim($value) !== '') {
|
|
$options[$option][] = $value;
|
|
$options[$option] = array_values(array_unique($options[$option]));
|
|
}
|
|
|
|
continue;
|
|
}
|
|
if (isset($match[2]) && $match[2] !== '') {
|
|
$value = $match[2];
|
|
$options[$option][] = $value;
|
|
$options[$option] = array_unique($options[$option]);
|
|
} else {
|
|
$value = true;
|
|
$options[$option] = $value;
|
|
}
|
|
}
|
|
$options = collect($options);
|
|
// Easily get mappings from https://github.com/composerize/composerize/blob/master/packages/composerize/src/mappings.js
|
|
foreach ($options as $option => $value) {
|
|
if (! data_get($mapping, $option)) {
|
|
continue;
|
|
}
|
|
if ($option === '--ulimit') {
|
|
$ulimits = collect([]);
|
|
collect($value)->map(function ($ulimit) use ($ulimits) {
|
|
$ulimit = explode('=', $ulimit);
|
|
$type = $ulimit[0];
|
|
$limits = explode(':', $ulimit[1]);
|
|
if (count($limits) == 2) {
|
|
$soft_limit = $limits[0];
|
|
$hard_limit = $limits[1];
|
|
$ulimits->put($type, [
|
|
'soft' => $soft_limit,
|
|
'hard' => $hard_limit,
|
|
]);
|
|
} else {
|
|
$soft_limit = $ulimit[1];
|
|
$ulimits->put($type, [
|
|
'soft' => $soft_limit,
|
|
]);
|
|
}
|
|
});
|
|
$compose_options->put($mapping[$option], $ulimits);
|
|
} elseif ($option === '--shm-size' || $option === '--hostname') {
|
|
if (! is_null($value) && is_array($value) && count($value) > 0 && ! empty(trim($value[0]))) {
|
|
$compose_options->put($mapping[$option], $value[0]);
|
|
}
|
|
} elseif ($option === '--entrypoint') {
|
|
if (! is_null($value) && is_array($value) && count($value) > 0 && ! empty(trim($value[0]))) {
|
|
// Docker compose accepts entrypoint as either a string or an array
|
|
// Keep it as a string for simplicity - docker compose will handle it
|
|
$compose_options->put($mapping[$option], $value[0]);
|
|
}
|
|
} elseif ($option === '--gpus') {
|
|
$payload = [
|
|
'driver' => 'nvidia',
|
|
'capabilities' => ['gpu'],
|
|
];
|
|
if (! is_null($value) && is_array($value) && count($value) > 0 && ! empty(trim($value[0]))) {
|
|
if (str($value[0]) != 'all') {
|
|
if (str($value[0])->contains(',')) {
|
|
$payload['device_ids'] = str($value[0])->explode(',')->toArray();
|
|
} else {
|
|
$payload['device_ids'] = [$value[0]];
|
|
}
|
|
}
|
|
}
|
|
$compose_options->put('deploy', [
|
|
'resources' => [
|
|
'reservations' => [
|
|
'devices' => [$payload],
|
|
],
|
|
],
|
|
]);
|
|
} else {
|
|
if ($list_options->contains($option)) {
|
|
if ($compose_options->has($mapping[$option])) {
|
|
$compose_options->put($mapping[$option], $options->get($mapping[$option]).','.$value);
|
|
} else {
|
|
$compose_options->put($mapping[$option], $value);
|
|
}
|
|
|
|
continue;
|
|
} else {
|
|
$compose_options->put($mapping[$option], $value);
|
|
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $compose_options->toArray();
|
|
}
|
|
|
|
function generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $network)
|
|
{
|
|
$ipv4 = data_get($docker_run_options, 'ip.0');
|
|
$ipv6 = data_get($docker_run_options, 'ip6.0');
|
|
data_forget($docker_run_options, 'ip');
|
|
data_forget($docker_run_options, 'ip6');
|
|
if ($ipv4 || $ipv6) {
|
|
data_forget($docker_compose['services'][$container_name], 'networks');
|
|
}
|
|
if ($ipv4) {
|
|
$docker_compose['services'][$container_name]['networks'][$network]['ipv4_address'] = $ipv4;
|
|
}
|
|
if ($ipv6) {
|
|
$docker_compose['services'][$container_name]['networks'][$network]['ipv6_address'] = $ipv6;
|
|
}
|
|
$docker_compose['services'][$container_name] = array_merge_recursive($docker_compose['services'][$container_name], $docker_run_options);
|
|
|
|
return $docker_compose;
|
|
}
|
|
|
|
/**
|
|
* Remove Coolify's custom Docker Compose fields from parsed YAML array
|
|
*
|
|
* Coolify extends Docker Compose with custom fields that are processed during
|
|
* parsing and deployment but must be removed before sending to Docker.
|
|
*
|
|
* Custom fields:
|
|
* - exclude_from_hc (service-level): Exclude service from health check monitoring
|
|
* - content (volume-level): Auto-create file with specified content during init
|
|
* - isDirectory / is_directory (volume-level): Mark bind mount as directory
|
|
*
|
|
* @param array $yamlCompose Parsed Docker Compose array
|
|
* @return array Cleaned Docker Compose array with custom fields removed
|
|
*/
|
|
function stripCoolifyCustomFields(array $yamlCompose): array
|
|
{
|
|
foreach ($yamlCompose['services'] ?? [] as $serviceName => $service) {
|
|
// Remove service-level custom fields
|
|
unset($yamlCompose['services'][$serviceName]['exclude_from_hc']);
|
|
|
|
// Remove volume-level custom fields (only for long syntax - arrays)
|
|
if (isset($service['volumes'])) {
|
|
foreach ($service['volumes'] as $volumeName => $volume) {
|
|
// Skip if volume is string (short syntax like 'db-data:/var/lib/postgresql/data')
|
|
if (! is_array($volume)) {
|
|
continue;
|
|
}
|
|
|
|
unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['content']);
|
|
unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['isDirectory']);
|
|
unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['is_directory']);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $yamlCompose;
|
|
}
|
|
|
|
function validateComposeFile(string $compose, int $server_id): string|Throwable
|
|
{
|
|
$uuid = Str::random(18);
|
|
$server = Server::ownedByCurrentTeam()->find($server_id);
|
|
try {
|
|
if (! $server) {
|
|
throw new \Exception('Server not found');
|
|
}
|
|
$yaml_compose = Yaml::parse($compose);
|
|
|
|
// Remove Coolify's custom fields before Docker validation
|
|
$yaml_compose = stripCoolifyCustomFields($yaml_compose);
|
|
|
|
$base64_compose = base64_encode(Yaml::dump($yaml_compose));
|
|
instant_remote_process([
|
|
"echo {$base64_compose} | base64 -d | tee /tmp/{$uuid}.yml > /dev/null",
|
|
"chmod 600 /tmp/{$uuid}.yml",
|
|
"docker compose -f /tmp/{$uuid}.yml config --no-interpolate --no-path-resolution -q",
|
|
"rm /tmp/{$uuid}.yml",
|
|
], $server);
|
|
|
|
return 'OK';
|
|
} catch (\Throwable $e) {
|
|
return $e->getMessage();
|
|
} finally {
|
|
if (filled($server)) {
|
|
instant_remote_process([
|
|
"rm /tmp/{$uuid}.yml",
|
|
], $server, throwError: false);
|
|
}
|
|
}
|
|
}
|
|
|
|
function getContainerLogs(Server $server, string $container_id, int $lines = 100): string
|
|
{
|
|
if ($server->isSwarm()) {
|
|
$output = instant_remote_process([
|
|
"docker service logs -n {$lines} {$container_id} 2>&1",
|
|
], $server);
|
|
} else {
|
|
$output = instant_remote_process([
|
|
"docker logs -n {$lines} {$container_id} 2>&1",
|
|
], $server);
|
|
}
|
|
|
|
$output = removeAnsiColors($output);
|
|
|
|
return $output;
|
|
}
|
|
function escapeEnvVariables($value)
|
|
{
|
|
$search = ['\\', "\r", "\t", "\x0", '"', "'"];
|
|
$replace = ['\\\\', '\\r', '\\t', '\\0', '\"', "\'"];
|
|
|
|
return str_replace($search, $replace, $value);
|
|
}
|
|
function escapeDollarSign($value)
|
|
{
|
|
$search = ['$'];
|
|
$replace = ['$$'];
|
|
|
|
return str_replace($search, $replace, $value);
|
|
}
|
|
|
|
/**
|
|
* Escape a value for use in a bash .env file that will be sourced with 'source' command
|
|
* Wraps the value in single quotes and escapes any single quotes within the value
|
|
*
|
|
* @param string|null $value The value to escape
|
|
* @return string The escaped value wrapped in single quotes
|
|
*/
|
|
function escapeBashEnvValue(?string $value): string
|
|
{
|
|
// Handle null or empty values
|
|
if ($value === null || $value === '') {
|
|
return "''";
|
|
}
|
|
|
|
// Replace single quotes with '\'' (end quote, escaped quote, start quote)
|
|
// This is the standard way to escape single quotes in bash single-quoted strings
|
|
$escaped = str_replace("'", "'\\''", $value);
|
|
|
|
// Wrap in single quotes
|
|
return "'{$escaped}'";
|
|
}
|
|
|
|
/**
|
|
* Escape a value for bash double-quoted strings (allows $VAR expansion)
|
|
*
|
|
* This function wraps values in double quotes while escaping special characters,
|
|
* but preserves valid bash variable references like $VAR and ${VAR}.
|
|
*
|
|
* @param string|null $value The value to escape
|
|
* @return string The escaped value wrapped in double quotes
|
|
*/
|
|
function escapeBashDoubleQuoted(?string $value): string
|
|
{
|
|
// Handle null or empty values
|
|
if ($value === null || $value === '') {
|
|
return '""';
|
|
}
|
|
|
|
// Step 1: Escape backslashes first (must be done before other escaping)
|
|
$escaped = str_replace('\\', '\\\\', $value);
|
|
|
|
// Step 2: Escape double quotes
|
|
$escaped = str_replace('"', '\\"', $escaped);
|
|
|
|
// Step 3: Escape backticks (command substitution)
|
|
$escaped = str_replace('`', '\\`', $escaped);
|
|
|
|
// Step 4: Escape invalid $ patterns while preserving valid variable references
|
|
// Valid patterns to keep:
|
|
// - $VAR_NAME (alphanumeric + underscore, starting with letter or _)
|
|
// - ${VAR_NAME} (brace expansion)
|
|
// - $0-$9 (positional parameters)
|
|
// Invalid patterns to escape: $&, $#, $$, $*, $@, $!, $(, etc.
|
|
|
|
// Match $ followed by anything that's NOT a valid variable start
|
|
// Valid variable starts: letter, underscore, digit (for $0-$9), or open brace
|
|
$escaped = preg_replace(
|
|
'/\$(?![a-zA-Z_0-9{])/',
|
|
'\\\$',
|
|
$escaped
|
|
);
|
|
|
|
// Preserve pre-escaped dollars inside double quotes: turn \\$ back into \$
|
|
// (keeps tests like "path\\to\\file" intact while restoring \$ semantics)
|
|
$escaped = preg_replace('/\\\\(?=\$)/', '\\\\', $escaped);
|
|
|
|
// Wrap in double quotes
|
|
return "\"{$escaped}\"";
|
|
}
|
|
|
|
/**
|
|
* Generate Docker build arguments from environment variables collection
|
|
* Returns only keys (no values) since values are sourced from environment via export
|
|
*
|
|
* @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
|
|
* @return \Illuminate\Support\Collection Collection of formatted --build-arg strings (keys only)
|
|
*/
|
|
function generateDockerBuildArgs($variables): \Illuminate\Support\Collection
|
|
{
|
|
$variables = collect($variables);
|
|
|
|
return $variables->map(function ($var) {
|
|
$key = is_array($var) ? data_get($var, 'key') : $var->key;
|
|
|
|
// Only return the key - Docker will get the value from the environment
|
|
return "--build-arg {$key}";
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Generate Docker environment flags from environment variables collection
|
|
*
|
|
* @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
|
|
* @return string Space-separated environment flags
|
|
*/
|
|
function generateDockerEnvFlags($variables): string
|
|
{
|
|
$variables = collect($variables);
|
|
|
|
return $variables
|
|
->map(function ($var) {
|
|
$key = is_array($var) ? data_get($var, 'key') : $var->key;
|
|
$value = is_array($var) ? data_get($var, 'value') : $var->value;
|
|
$isMultiline = is_array($var) ? data_get($var, 'is_multiline', false) : ($var->is_multiline ?? false);
|
|
|
|
if ($isMultiline) {
|
|
// For multiline variables, strip surrounding quotes and escape for bash
|
|
$raw_value = trim($value, "'");
|
|
$escaped_value = str_replace(['\\', '"', '$', '`'], ['\\\\', '\\"', '\\$', '\\`'], $raw_value);
|
|
|
|
return "-e {$key}=\"{$escaped_value}\"";
|
|
}
|
|
|
|
$escaped_value = escapeshellarg($value);
|
|
|
|
return "-e {$key}={$escaped_value}";
|
|
})
|
|
->implode(' ');
|
|
}
|
|
|
|
/**
|
|
* Auto-inject -f and --env-file flags into a docker compose command if not already present
|
|
*
|
|
* @param string $command The docker compose command to modify
|
|
* @param string $composeFilePath The path to the compose file
|
|
* @param string $envFilePath The path to the .env file
|
|
* @return string The modified command with injected flags
|
|
*
|
|
* @example
|
|
* Input: "docker compose build"
|
|
* Output: "docker compose -f ./docker-compose.yml --env-file .env build"
|
|
*/
|
|
function injectDockerComposeFlags(string $command, string $composeFilePath, string $envFilePath): string
|
|
{
|
|
$dockerComposeReplacement = 'docker compose';
|
|
|
|
// Add -f flag if not present (checks for both -f and --file with various formats)
|
|
// Detects: -f path, -f=path, -fpath (concatenated with path chars: . / ~), --file path, --file=path
|
|
// Note: Uses [.~/]|$ instead of \S to prevent false positives with flags like -foo, -from, -feature
|
|
if (! preg_match('/(?:^|\s)(?:-f(?:[=\s]|[.\/~]|$)|--file(?:=|\s))/', $command)) {
|
|
$dockerComposeReplacement .= " -f {$composeFilePath}";
|
|
}
|
|
|
|
// Add --env-file flag if not present (checks for --env-file with various formats)
|
|
// Detects: --env-file path, --env-file=path with any whitespace
|
|
if (! preg_match('/(?:^|\s)--env-file(?:=|\s)/', $command)) {
|
|
$dockerComposeReplacement .= " --env-file {$envFilePath}";
|
|
}
|
|
|
|
// Replace only first occurrence to avoid modifying comments/strings/chained commands
|
|
return preg_replace('/docker\s+compose/', $dockerComposeReplacement, $command, 1);
|
|
}
|