Adds support for deploying Garage (S3-compatible object storage) as a one-click service in Coolify. Includes service template with TOML config, automatic URL generation for S3, Web, and Admin endpoints with reverse proxy configuration, and UI fields for credentials and access tokens. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1483 lines
62 KiB
PHP
1483 lines
62 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;
|
|
case $type?->contains('garage'):
|
|
$GARAGE_S3_API_URL = $variables->where('key', 'GARAGE_S3_API_URL')->first();
|
|
$GARAGE_WEB_URL = $variables->where('key', 'GARAGE_WEB_URL')->first();
|
|
$GARAGE_ADMIN_URL = $variables->where('key', 'GARAGE_ADMIN_URL')->first();
|
|
|
|
if (is_null($GARAGE_S3_API_URL) || is_null($GARAGE_WEB_URL) || is_null($GARAGE_ADMIN_URL)) {
|
|
return collect([]);
|
|
}
|
|
|
|
if (str($GARAGE_S3_API_URL->value ?? '')->isEmpty()) {
|
|
$GARAGE_S3_API_URL->update([
|
|
'value' => generateUrl(server: $server, random: 's3-'.$uuid, forceHttps: true),
|
|
]);
|
|
}
|
|
if (str($GARAGE_WEB_URL->value ?? '')->isEmpty()) {
|
|
$GARAGE_WEB_URL->update([
|
|
'value' => generateUrl(server: $server, random: 'web-'.$uuid, forceHttps: true),
|
|
]);
|
|
}
|
|
if (str($GARAGE_ADMIN_URL->value ?? '')->isEmpty()) {
|
|
$GARAGE_ADMIN_URL->update([
|
|
'value' => generateUrl(server: $server, random: 'admin-'.$uuid, forceHttps: true),
|
|
]);
|
|
}
|
|
$payload = collect([
|
|
$GARAGE_S3_API_URL->value.':3900',
|
|
$GARAGE_WEB_URL->value.':3902',
|
|
$GARAGE_ADMIN_URL->value.':3903',
|
|
]);
|
|
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(':');
|
|
|
|
// Extract base image name (ignore registry prefix)
|
|
// Examples:
|
|
// docker.io/library/postgres -> postgres
|
|
// ghcr.io/postgrest/postgrest -> postgrest
|
|
// postgres -> postgres
|
|
// postgrest/postgrest -> postgrest
|
|
$baseImageName = $imageName;
|
|
if (str($imageName)->contains('/')) {
|
|
$baseImageName = str($imageName)->afterLast('/');
|
|
}
|
|
|
|
// Check if base image name exactly matches a known database image
|
|
$isKnownDatabase = false;
|
|
foreach (DATABASE_DOCKER_IMAGES as $database_docker_image) {
|
|
// Extract base name from database pattern for comparison
|
|
$databaseBaseName = str($database_docker_image)->contains('/')
|
|
? str($database_docker_image)->afterLast('/')
|
|
: $database_docker_image;
|
|
|
|
if ($baseImageName == $databaseBaseName) {
|
|
$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);
|
|
}
|
|
|
|
/**
|
|
* Inject build arguments right after build-related subcommands in docker/docker compose commands.
|
|
* This ensures build args are only applied to build operations, not to push, pull, up, etc.
|
|
*
|
|
* Supports:
|
|
* - docker compose build
|
|
* - docker buildx build
|
|
* - docker builder build
|
|
* - docker build (legacy)
|
|
*
|
|
* Examples:
|
|
* - Input: "docker compose -f file.yml build"
|
|
* Output: "docker compose -f file.yml build --build-arg X --build-arg Y"
|
|
*
|
|
* - Input: "docker buildx build --platform linux/amd64"
|
|
* Output: "docker buildx build --build-arg X --build-arg Y --platform linux/amd64"
|
|
*
|
|
* - Input: "docker builder build --tag myimage:latest"
|
|
* Output: "docker builder build --build-arg X --build-arg Y --tag myimage:latest"
|
|
*
|
|
* - Input: "docker compose build && docker compose push"
|
|
* Output: "docker compose build --build-arg X --build-arg Y && docker compose push"
|
|
*
|
|
* - Input: "docker compose push"
|
|
* Output: "docker compose push" (unchanged - no build command found)
|
|
*
|
|
* @param string $command The docker command
|
|
* @param string $buildArgsString The build arguments to inject (e.g., "--build-arg X --build-arg Y")
|
|
* @return string The modified command with build args injected after build subcommand
|
|
*/
|
|
function injectDockerComposeBuildArgs(string $command, string $buildArgsString): string
|
|
{
|
|
// Early return if no build args to inject
|
|
if (empty(trim($buildArgsString))) {
|
|
return $command;
|
|
}
|
|
|
|
// Match build-related commands:
|
|
// - ' builder build' (docker builder build)
|
|
// - ' buildx build' (docker buildx build)
|
|
// - ' build' (docker compose build, docker build)
|
|
// Followed by either:
|
|
// - whitespace (allowing service names, flags, or any valid arguments)
|
|
// - end of string ($)
|
|
// This regex ensures we match build subcommands, not "build" in other contexts
|
|
// IMPORTANT: Order matters - check longer patterns first (builder build, buildx build) before ' build'
|
|
$pattern = '/( builder build| buildx build| build)(?=\s|$)/';
|
|
|
|
// Replace the first occurrence of build command with build command + build-args
|
|
$modifiedCommand = preg_replace(
|
|
$pattern,
|
|
'$1 '.$buildArgsString,
|
|
$command,
|
|
1 // Only replace first occurrence
|
|
);
|
|
|
|
return $modifiedCommand ?? $command;
|
|
}
|