mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
Unset the helper's build secret variables before Compose pulls or starts services, so containers get the runtime .env values. Give build secrets the raw stored value instead of the quoted .env form. Closes #12409 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2288 lines
98 KiB
PHP
2288 lines
98 KiB
PHP
<?php
|
|
|
|
use App\Enums\ProxyTypes;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationPreview;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceApplication;
|
|
use App\Models\ServiceDatabase;
|
|
use App\Support\DomainPortOverrides;
|
|
use App\Support\ValidationPatterns;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
use Spatie\Url\Url;
|
|
use Symfony\Component\Yaml\Yaml;
|
|
|
|
/**
|
|
* Stable short hash for compose service names used in Traefik router/service IDs.
|
|
* Keeps api.test vs api-test distinct after label-safe normalization.
|
|
*/
|
|
function traefikServiceNameHash(string $serviceName): string
|
|
{
|
|
return substr(md5($serviceName), 0, 4);
|
|
}
|
|
|
|
/**
|
|
* Label-safe Traefik router/service name segment for a compose service.
|
|
* Dots (and other non [a-zA-Z0-9-]) break Docker/Traefik label paths; always append a hash
|
|
* so normalized names like api.test and api-test never collide.
|
|
*/
|
|
function traefikSafeServiceNameSegment(string $serviceName): string
|
|
{
|
|
$normalized = str($serviceName)
|
|
->replace('.', '-')
|
|
->replace('_', '-')
|
|
->replaceMatches('/[^a-zA-Z0-9-]+/', '-')
|
|
->replaceMatches('/-+/', '-')
|
|
->trim('-')
|
|
->toString();
|
|
|
|
if ($normalized === '') {
|
|
$normalized = 'service';
|
|
}
|
|
|
|
return $normalized.'-'.traefikServiceNameHash($serviceName);
|
|
}
|
|
|
|
/**
|
|
* Key that Sentinel uses to group Caddy access-log lines. It is the same key that Sentinel gets
|
|
* from the Traefik router name (`https-{i}-{KEY}@docker`), so both proxies report a resource the same way.
|
|
* Only [A-Za-z0-9-] can occur, so the value is safe in a Docker label and in a Caddyfile.
|
|
*/
|
|
function caddyTrafficAppKey(string $uuid, ?string $serviceName = null): string
|
|
{
|
|
// Same truthiness check as fqdnLabelsForTraefik(), so the keys stay equal.
|
|
$key = $serviceName ? $uuid.'-'.traefikSafeServiceNameSegment($serviceName) : $uuid;
|
|
|
|
return (string) preg_replace('/[^A-Za-z0-9-]+/', '-', $key);
|
|
}
|
|
|
|
/**
|
|
* UUID of the resource that owns a container.
|
|
*
|
|
* New containers carry `coolify.{type}Uuid`. Containers created before that label carry a
|
|
* numeric `coolify.{type}Id` instead. Ids change when a server moves to another instance, so
|
|
* for those containers the owner is read from the compose project (Swarm: stack namespace),
|
|
* which is the resource UUID for deployments since mid-2024.
|
|
*
|
|
* @param 'application'|'service'|'database' $type
|
|
*/
|
|
function containerOwnerUuid(Collection|array|string $labels, string $type): ?string
|
|
{
|
|
$labels = is_string($labels) ? format_docker_labels_to_json($labels) : collect($labels);
|
|
|
|
$uuid = $labels->get("coolify.{$type}Uuid");
|
|
if (filled($uuid)) {
|
|
return (string) $uuid;
|
|
}
|
|
|
|
if (! $labels->has("coolify.{$type}Id")) {
|
|
return null;
|
|
}
|
|
|
|
$legacyUuid = $labels->get('com.docker.compose.project') ?? $labels->get('com.docker.stack.namespace');
|
|
|
|
return filled($legacyUuid) ? (string) $legacyUuid : null;
|
|
}
|
|
|
|
/**
|
|
* Resource that owns a container, from the given candidates, by UUID label or compose project /
|
|
* stack namespace (see containerOwnerUuid()). Some older application containers have another
|
|
* compose project: the deployment directory (before mid-2024, or a custom Compose start command
|
|
* without --project-name). Their container name (`coolify.name`, for Dockerfile-style builds also
|
|
* the compose service) contains the application UUID as a dash-separated part, for example
|
|
* `{uuid}-104512123456` or `{service}-{uuid}-104512123456`. The numeric id label is never used:
|
|
* another Coolify instance that manages the same server can have a resource with the same id.
|
|
*
|
|
* @template TResource of \Illuminate\Database\Eloquent\Model
|
|
*
|
|
* @param Collection<int, TResource> $resources
|
|
* @param 'application'|'service'|'database' $type
|
|
* @return TResource|null
|
|
*/
|
|
function resolveContainerOwner(Collection $resources, Collection|array|string $labels, string $type): mixed
|
|
{
|
|
$labels = is_string($labels) ? format_docker_labels_to_json($labels) : collect($labels);
|
|
|
|
$uuid = containerOwnerUuid($labels, $type);
|
|
$owner = $uuid ? $resources->firstWhere('uuid', $uuid) : null;
|
|
if ($owner || $type !== 'application' || filled($labels->get('coolify.applicationUuid')) || ! $labels->has('coolify.applicationId')) {
|
|
return $owner;
|
|
}
|
|
|
|
$names = [(string) $labels->get('com.docker.compose.service'), (string) $labels->get('coolify.name')];
|
|
|
|
return $resources->first(fn ($resource) => collect($names)->contains(
|
|
fn (string $name) => filled($resource->uuid) && preg_match('/(?:^|-)'.preg_quote($resource->uuid, '/').'(?:-|$)/', $name) === 1
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Id of the application that owns a container, also for applications not in $applications
|
|
* (for example the parent of a preview). Same order as resolveContainerOwner().
|
|
*
|
|
* @param array<string, int>|null $applicationIdsByUuid Owners outside $applications, preloaded with
|
|
* containerApplicationIdsByUuid(); null queries per call.
|
|
*/
|
|
function resolveContainerApplicationId(Collection $applications, Collection|array|string $labels, ?array $applicationIdsByUuid = null): ?int
|
|
{
|
|
$labels = is_string($labels) ? format_docker_labels_to_json($labels) : collect($labels);
|
|
|
|
$owner = resolveContainerOwner($applications, $labels, 'application');
|
|
if ($owner) {
|
|
return (int) $owner->id;
|
|
}
|
|
|
|
$uuid = containerOwnerUuid($labels, 'application');
|
|
if (! $uuid) {
|
|
return null;
|
|
}
|
|
if ($applicationIdsByUuid !== null) {
|
|
return $applicationIdsByUuid[$uuid] ?? null;
|
|
}
|
|
|
|
$id = Application::withTrashed()->where('uuid', $uuid)->value('id');
|
|
|
|
return $id !== null ? (int) $id : null;
|
|
}
|
|
|
|
/**
|
|
* Ids of the applications (including trashed ones) that own the given containers but are not in
|
|
* $applications, keyed by owner UUID, in one query. Pass the result to resolveContainerApplicationId()
|
|
* so a batch of containers does not query once per container.
|
|
*
|
|
* @param iterable<Collection|array|string> $containerLabels
|
|
* @return array<string, int>
|
|
*/
|
|
function containerApplicationIdsByUuid(Collection $applications, iterable $containerLabels): array
|
|
{
|
|
$uuids = [];
|
|
foreach ($containerLabels as $labels) {
|
|
$uuid = containerOwnerUuid($labels, 'application');
|
|
if ($uuid) {
|
|
$uuids[$uuid] = true;
|
|
}
|
|
}
|
|
foreach ($applications->pluck('uuid')->filter() as $knownUuid) {
|
|
unset($uuids[$knownUuid]);
|
|
}
|
|
if ($uuids === []) {
|
|
return [];
|
|
}
|
|
|
|
return Application::withTrashed()
|
|
->whereIn('uuid', array_keys($uuids))
|
|
->pluck('id', 'uuid')
|
|
->map(fn ($id) => (int) $id)
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* Whether a container belongs to a resource of the given type (by UUID or older numeric id label).
|
|
*
|
|
* @param 'application'|'service'|'database' $type
|
|
*/
|
|
function isContainerOfType(Collection|array|string $labels, string $type): bool
|
|
{
|
|
$labels = is_string($labels) ? format_docker_labels_to_json($labels) : collect($labels);
|
|
|
|
return filled($labels->get("coolify.{$type}Uuid")) || filled($labels->get("coolify.{$type}Id"));
|
|
}
|
|
|
|
/**
|
|
* Service, sub type, and service application/database that own a service container.
|
|
*
|
|
* The part is matched by `coolify.service.subUuid`; containers from before that label are matched
|
|
* by their compose service key (the part name), then by the older numeric `coolify.service.subId`.
|
|
*
|
|
* @param Collection<int, Service> $services
|
|
* @return array{0: ?Service, 1: ?string, 2: ServiceApplication|ServiceDatabase|null}
|
|
*/
|
|
function resolveServiceContainerOwner(Collection $services, Collection|array $flatLabels): array
|
|
{
|
|
$labels = collect($flatLabels);
|
|
$service = resolveContainerOwner($services, $labels, 'service');
|
|
if (! $service) {
|
|
return [null, null, null];
|
|
}
|
|
|
|
$subType = $labels->get('coolify.service.subType');
|
|
$parts = $subType === 'database' ? $service->databases : $service->applications;
|
|
$subUuid = $labels->get('coolify.service.subUuid');
|
|
if (filled($subUuid)) {
|
|
return [$service, $subType, $parts->firstWhere('uuid', $subUuid)];
|
|
}
|
|
|
|
$legacySubId = $labels->get('coolify.service.subId');
|
|
$part = $parts->firstWhere('name', $labels->get('com.docker.compose.service'))
|
|
?? (is_numeric($legacySubId) ? $parts->first(fn ($part) => (int) $part->id === (int) $legacySubId) : null);
|
|
|
|
return [$service, $subType, $part];
|
|
}
|
|
|
|
/**
|
|
* Remote commands that list, as JSON lines, the containers owned by a resource (see containerOwnerUuid()).
|
|
*
|
|
* @param 'application'|'service'|'database' $type
|
|
* @param list<string> $extraFilters Additional `--filter` values, e.g. `label=coolify.pullRequestId=0`.
|
|
* @param list<string>|null $legacyExtraFilters Filters for containers without the UUID label; defaults to $extraFilters.
|
|
* @return list<string>
|
|
*/
|
|
function dockerPsByOwnerCommands(string $type, string $uuid, array $extraFilters = [], string $format = '{{json .}}', bool $all = true, ?array $legacyExtraFilters = null): array
|
|
{
|
|
$filters = fn (array $values) => collect($values)->map(fn (string $filter) => '--filter '.escapeshellarg($filter))->implode(' ');
|
|
$base = 'docker ps'.($all ? ' -a' : '');
|
|
$formatArgument = '--format '.escapeshellarg($format);
|
|
$legacyFilters = $legacyExtraFilters ?? $extraFilters;
|
|
|
|
$commands = [
|
|
$base.' '.$filters(["label=coolify.{$type}Uuid={$uuid}", ...$extraFilters]).' '.$formatArgument,
|
|
$base.' '.$filters(["label=coolify.{$type}Id", "label=com.docker.compose.project={$uuid}", ...$legacyFilters]).' '.$formatArgument,
|
|
];
|
|
if ($type === 'application') {
|
|
// Compose project is the deployment directory (see resolveContainerOwner()): the container name contains the UUID.
|
|
$commands[] = $base.' '.$filters(['label=coolify.applicationId', "name={$uuid}", ...$legacyFilters]).' '.$formatArgument;
|
|
}
|
|
|
|
return $commands;
|
|
}
|
|
|
|
/**
|
|
* Run dockerPsByOwnerCommands() for a resource and return each container once.
|
|
*
|
|
* @param 'application'|'service'|'database' $type
|
|
*/
|
|
function containersOwnedBy(Server $server, string $type, Model $resource): Collection
|
|
{
|
|
$output = instant_remote_process(dockerPsByOwnerCommands($type, $resource->uuid), $server);
|
|
|
|
return format_docker_command_output_to_json($output)->filter()->unique('ID')->values();
|
|
}
|
|
|
|
/**
|
|
* One shell line that sets $container_ids to the ids of the containers owned by a resource.
|
|
*
|
|
* @param 'application'|'service'|'database' $type
|
|
* @param list<string> $extraFilters
|
|
* @param list<string>|null $legacyExtraFilters
|
|
*/
|
|
function containerIdsByOwnerScript(string $type, string $uuid, array $extraFilters = [], bool $all = true, ?array $legacyExtraFilters = null): string
|
|
{
|
|
$commands = dockerPsByOwnerCommands($type, $uuid, $extraFilters, '{{.ID}}', $all, $legacyExtraFilters);
|
|
|
|
return 'container_ids=$({ '.implode('; ', $commands).'; } | sort -u)';
|
|
}
|
|
|
|
function getCurrentApplicationContainerStatus(Server $server, Application $application, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection
|
|
{
|
|
$containers = collect([]);
|
|
if (! $server->isSwarm()) {
|
|
$containers = containersOwnedBy($server, 'application', $application);
|
|
|
|
$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 && (int) $prLabelValue === $pullRequestId) {
|
|
return $container;
|
|
}
|
|
|
|
return null;
|
|
});
|
|
|
|
$filtered = $containers->filter();
|
|
|
|
return $filtered;
|
|
}
|
|
|
|
return $containers;
|
|
}
|
|
|
|
function getCurrentServiceContainerStatus(Server $server, Service $service): Collection
|
|
{
|
|
$containers = collect([]);
|
|
if (! $server->isSwarm()) {
|
|
return containersOwnedBy($server, 'service', $service);
|
|
}
|
|
|
|
return $containers;
|
|
}
|
|
|
|
function getCurrentDatabaseContainerStatus(Server $server, Model $database): Collection
|
|
{
|
|
$containers = collect([]);
|
|
if (! $server->isSwarm()) {
|
|
return containersOwnedBy($server, 'database', $database);
|
|
}
|
|
|
|
return $containers;
|
|
}
|
|
|
|
function getCurrentServiceSubContainerStatus(Server $server, Service $service, string $name): Collection
|
|
{
|
|
return filterServiceSubContainersByName(getCurrentServiceContainerStatus($server, $service), $name);
|
|
}
|
|
|
|
function filterServiceSubContainersByName(Collection $containers, string $name): Collection
|
|
{
|
|
return $containers->filter(function ($container) use ($name) {
|
|
$labels = data_get($container, 'Labels', []);
|
|
if (is_string($labels)) {
|
|
$labels = format_docker_labels_to_json($labels);
|
|
}
|
|
|
|
return collect($labels)->get('coolify.name') === $name;
|
|
})->values();
|
|
}
|
|
|
|
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)
|
|
->mapWithKeys(function ($outputLine) {
|
|
$label = explode('=', $outputLine, 2);
|
|
if (count($label) !== 2) {
|
|
return [];
|
|
}
|
|
|
|
return [$label[0] => $label[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 = (int) str($dockerVersion)->before('.')->value();
|
|
$requiredDockerVersion = (int) str(config('constants.docker.minimum_required_version'))->before('.')->value();
|
|
if ($majorDockerVersion < $requiredDockerVersion) {
|
|
$dockerVersion = null;
|
|
}
|
|
|
|
return $dockerVersion;
|
|
}
|
|
|
|
function parseDockerEngineVersion(?string $rawVersion): ?string
|
|
{
|
|
if ($rawVersion === null || trim($rawVersion) === '') {
|
|
return null;
|
|
}
|
|
|
|
if (preg_match('/\d+\.\d+(?:\.\d+)?/', $rawVersion, $matches) !== 1) {
|
|
return null;
|
|
}
|
|
|
|
$parts = explode('.', $matches[0]);
|
|
|
|
return sprintf('%d.%d.%d', (int) $parts[0], (int) ($parts[1] ?? 0), (int) ($parts[2] ?? 0));
|
|
}
|
|
|
|
function dockerEngineVersionFromJson(?string $raw): ?string
|
|
{
|
|
if ($raw === null || trim($raw) === '') {
|
|
return null;
|
|
}
|
|
|
|
$decoded = json_decode($raw, true);
|
|
if (! is_array($decoded)) {
|
|
return null;
|
|
}
|
|
|
|
$version = $decoded['Server']['Version'] ?? null;
|
|
|
|
return is_string($version) ? parseDockerEngineVersion($version) : null;
|
|
}
|
|
|
|
function dockerStopTimeoutOption(?string $dockerVersion): string
|
|
{
|
|
$normalized = parseDockerEngineVersion($dockerVersion);
|
|
if ($normalized !== null && version_compare($normalized, '28.0.0', '>=')) {
|
|
return '--timeout';
|
|
}
|
|
|
|
return '--time';
|
|
}
|
|
|
|
function dockerStopCommand(int $timeout, string $containers, Server|string|null $dockerVersion = null): string
|
|
{
|
|
$version = $dockerVersion instanceof Server
|
|
? $dockerVersion->dockerVersion()
|
|
: $dockerVersion;
|
|
|
|
$option = dockerStopTimeoutOption($version);
|
|
$flag = $option === '--timeout'
|
|
? "--timeout={$timeout}"
|
|
: "--time={$timeout}";
|
|
|
|
$command = "docker stop {$flag} {$containers}";
|
|
|
|
if (app()->bound('config') && isDev()) {
|
|
Log::info('docker stop command', [
|
|
'command' => $command,
|
|
'docker_version' => $version,
|
|
]);
|
|
}
|
|
|
|
return $command;
|
|
}
|
|
|
|
function dockerRemoveCommandWithTimeout(string $container, int $timeout = 60, int $killAfter = 10): string
|
|
{
|
|
$container = escapeShellValue($container);
|
|
$script = "if command -v timeout >/dev/null 2>&1; then output=\$(timeout -k {$killAfter}s {$timeout}s docker rm -f {$container} 2>&1); exit_code=\$?; else output=''; exit_code=124; fi; if [ \"\$exit_code\" -eq 124 ]; then echo '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'; elif [ \"\$exit_code\" -ne 0 ] && printf '%s' \"\$output\" | grep -q 'No such container:'; then exit 0; elif [ \"\$exit_code\" -ne 0 ]; then printf '%s\\n' \"\$output\" >&2; else printf '%s\\n' \"\$output\"; fi; exit \$exit_code";
|
|
|
|
return 'bash -c '.escapeShellValue($script);
|
|
}
|
|
|
|
function dockerRemoveCommand(string $container): string
|
|
{
|
|
$command = 'docker rm -f '.escapeShellValue($container);
|
|
|
|
return dockerCommandIgnoringError($command, 'No such container:');
|
|
}
|
|
|
|
function dockerNetworkRemoveCommand(string $network): string
|
|
{
|
|
$command = 'docker network rm '.escapeShellValue($network);
|
|
|
|
return dockerCommandIgnoringError($command, 'network .* not found');
|
|
}
|
|
|
|
function dockerCommandIgnoringError(string $command, string $ignoredError): string
|
|
{
|
|
$script = "output=\$({$command} 2>&1); exit_code=\$?; if [ \"\$exit_code\" -ne 0 ] && printf '%s' \"\$output\" | grep -Eq ".escapeShellValue($ignoredError)."; then exit 0; fi; if [ \"\$exit_code\" -ne 0 ]; then printf '%s\\n' \"\$output\" >&2; else printf '%s\\n' \"\$output\"; fi; exit \$exit_code";
|
|
|
|
return 'bash -c '.escapeShellValue($script);
|
|
}
|
|
|
|
function escapeShellValue(string $value): string
|
|
{
|
|
return "'".str_replace("'", "'\\''", $value)."'";
|
|
}
|
|
|
|
/**
|
|
* Prefix for a Docker command that pulls on the server itself, outside the helper container. It makes the CLI use
|
|
* the SSH user's Docker config, where the registry logins are and which the helper container mounts. On a non-root
|
|
* server the command runs with sudo, which resets HOME to /root, so without it a private image pull is anonymous.
|
|
* The SSH user's shell expands $HOME before sudo runs. Without a config of the SSH user, DOCKER_CONFIG is empty and
|
|
* the CLI keeps /root/.docker, so a login made with `sudo docker login` still works. DOCKER_CONTEXT=default keeps the
|
|
* pull on the host daemon if that config selects another context, because the next commands use the host daemon.
|
|
*/
|
|
function serverDockerConfigPrefix(Server $server): string
|
|
{
|
|
// No && or pipe: with them, the sudo parser can wrap the line in `sudo bash -c`, where $HOME is /root.
|
|
return $server->isNonRoot() ? 'DOCKER_CONTEXT=default DOCKER_CONFIG="$(find "$HOME/.docker/config.json" -maxdepth 0 -exec dirname {} \\; 2>/dev/null)" ' : '';
|
|
}
|
|
|
|
function executeInDocker(string $containerId, string $command)
|
|
{
|
|
$escapedCommand = str_replace("'", "'\\''", $command);
|
|
|
|
return "docker exec {$containerId} bash -c '{$escapedCommand}'";
|
|
}
|
|
|
|
function buildContainerStatusCommand(Server $server, string $container_id): string
|
|
{
|
|
if ($server->isSwarm()) {
|
|
return 'docker service ls --filter '.escapeshellarg("name={$container_id}")." --format '{{json .}}' ";
|
|
}
|
|
|
|
return "docker inspect --format '{{json .}}' ".escapeshellarg($container_id);
|
|
}
|
|
|
|
function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false)
|
|
{
|
|
$container = instant_remote_process([buildContainerStatusCommand($server, $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;
|
|
$name = $consistent_container_name ? ($application->settings->custom_internal_name ?: $application->uuid) : $application->uuid;
|
|
$now = now()->format('Ymd\THis');
|
|
if ($pull_request_id !== 0 && $pull_request_id !== null) {
|
|
return $name.'-pr-'.$pull_request_id;
|
|
} else {
|
|
if ($consistent_container_name) {
|
|
return $name;
|
|
}
|
|
|
|
return ($application->settings->custom_container_name_prefix ?: $application->uuid).'-'.$now;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generated (rolling update) container names end with the timestamp from generateApplicationContainerName().
|
|
* Drop the legacy pattern once containers created before the ISO 8601 suffix are gone.
|
|
*/
|
|
function isGeneratedContainerName(string $containerName): bool
|
|
{
|
|
$isoTimestampSuffix = '/-\d{8}T\d{6}$/';
|
|
$legacyTimestampSuffix = '/-\d{12}$/';
|
|
|
|
return preg_match($isoTimestampSuffix, $containerName) === 1
|
|
|| preg_match($legacyTimestampSuffix, $containerName) === 1;
|
|
}
|
|
|
|
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.databaseUuid='.$database->uuid);
|
|
$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(string $uuid, $name, string $projectName, string $resourceName, string $environment, $pull_request_id = 0, string $type = 'application', $subType = null, ?string $subUuid = null, $subName = null)
|
|
{
|
|
$labels = collect([]);
|
|
$labels->push('coolify.managed=true');
|
|
$labels->push('coolify.version='.config('constants.coolify.version'));
|
|
$labels->push('coolify.'.$type.'Uuid='.$uuid);
|
|
$labels->push("coolify.type=$type");
|
|
$labels->push('coolify.name='.Str::slug($name));
|
|
$labels->push('coolify.resourceName='.Str::slug($resourceName));
|
|
$labels->push('coolify.projectName='.Str::slug($projectName));
|
|
$labels->push('coolify.serviceName='.Str::slug($subName ?? $resourceName));
|
|
$labels->push('coolify.environmentName='.Str::slug($environment));
|
|
|
|
$labels->push('coolify.pullRequestId='.$pull_request_id);
|
|
if ($type === 'service') {
|
|
$subUuid && $labels->push('coolify.service.subUuid='.$subUuid);
|
|
$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() === 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() === 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', 'pgsty/silo']):
|
|
$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;
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, string>|null $noindex_domains
|
|
*/
|
|
function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool
|
|
{
|
|
if (blank($noindex_domains)) {
|
|
return false;
|
|
}
|
|
|
|
// Flags are stored without a port, so compare portless: https://app.example.com:3000 matches https://app.example.com.
|
|
$normalize = fn (string $url) => DomainPortOverrides::withoutPort(ValidationPatterns::normalizeApplicationDomainUrl($url));
|
|
|
|
return $noindex_domains
|
|
->map($normalize)
|
|
->contains($normalize($domain));
|
|
}
|
|
|
|
/**
|
|
* Caddy site address of a domain. Maintenance blocks in the base Caddyfile must use the same
|
|
* address as the labels, so caddy-docker-proxy merges them into one site block.
|
|
*/
|
|
function caddySiteAddress(string $scheme, string $host, bool $isForceHttpsEnabled): string
|
|
{
|
|
if ($scheme === 'https' && ! $isForceHttpsEnabled) {
|
|
return "http://{$host}, https://{$host}";
|
|
}
|
|
|
|
return "{$scheme}://{$host}";
|
|
}
|
|
|
|
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, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false, ?int $http_basic_auth_bcrypt_cost = null, array $http_basic_auth_argon2id_options = [], bool $is_permanent_redirect_enabled = false)
|
|
{
|
|
$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 = $http_basic_auth_argon2id_options
|
|
? password_hash($http_basic_auth_password, PASSWORD_ARGON2ID, $http_basic_auth_argon2id_options)
|
|
: password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => $http_basic_auth_bcrypt_cost ?? 10]);
|
|
}
|
|
|
|
$trafficAppKey = caddyTrafficAppKey($uuid, $service_name);
|
|
|
|
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();
|
|
$siteAddress = caddySiteAddress($schema, $host, $is_force_https_enabled);
|
|
$portlessDomain = ServiceApplication::withoutPort($domain);
|
|
$port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null);
|
|
$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}={$siteAddress}");
|
|
if (isNoindexDomain($domain, $noindex_domains)) {
|
|
// Caddy's header directive takes either inline arguments or a block,
|
|
// never both, so -Server has to move into the block alongside it.
|
|
$labels->push("caddy_{$loop}.header.0_-Server=");
|
|
$labels->push("caddy_{$loop}.header.1_X-Robots-Tag=\"noindex, nofollow\"");
|
|
} else {
|
|
$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");
|
|
}
|
|
$redirect_schema = $is_force_https_enabled ? $schema : '{scheme}';
|
|
$redirect_code = $is_permanent_redirect_enabled ? ' permanent' : '';
|
|
if ($redirect_direction === 'www' && ! str($host)->startsWith('www.')) {
|
|
$labels->push("caddy_{$loop}.redir={$redirect_schema}://www.{$host}{uri}{$redirect_code}");
|
|
}
|
|
if ($redirect_direction === 'non-www' && str($host)->startsWith('www.')) {
|
|
$labels->push("caddy_{$loop}.redir={$redirect_schema}://{$host_without_www}{uri}{$redirect_code}");
|
|
}
|
|
if ($is_http_basic_auth_enabled) {
|
|
// Caddy 2.8 renamed basicauth to basic_auth; see Server::caddySupportsBasicAuthDirective().
|
|
$basicAuthDirective = $supports_basic_auth_directive ? 'basic_auth' : 'basicauth';
|
|
if ($http_basic_auth_argon2id_options) {
|
|
$labels->push("caddy_{$loop}.{$basicAuthDirective}=argon2id");
|
|
}
|
|
$labels->push("caddy_{$loop}.{$basicAuthDirective}.{$http_basic_auth_username}=\"{$hashedPassword}\"");
|
|
}
|
|
if ($is_traffic_analytics_enabled) {
|
|
$labels->push("caddy_{$loop}.log.output=file /traffic/access.log");
|
|
// Explicit lumberjack roll options so the access log doesn't grow unbounded
|
|
// (Caddy's defaults are undocumented). caddy-docker-proxy renders these dotted
|
|
// keys as a nested block: output file /traffic/access.log { roll_size 20MiB; roll_keep 5; roll_keep_for 168h }.
|
|
// Rotation is rename-based, which is safe for Sentinel's tailer (it reopens on inode change).
|
|
$labels->push("caddy_{$loop}.log.output.roll_size=20MiB");
|
|
$labels->push("caddy_{$loop}.log.output.roll_keep=5");
|
|
$labels->push("caddy_{$loop}.log.output.roll_keep_for=168h");
|
|
$labels->push("caddy_{$loop}.log.format=json");
|
|
// Only Caddy 2.8+ knows log_append; see Server::caddySupportsLogAppend().
|
|
if ($supports_log_append) {
|
|
$labels->push("caddy_{$loop}.log_append=coolify_app_id {$trafficAppKey}");
|
|
}
|
|
}
|
|
}
|
|
|
|
return $labels->sort();
|
|
}
|
|
|
|
function firstDockerComposeServicePort(mixed $service): ?int
|
|
{
|
|
$portDefinitions = collect(data_get($service, 'expose', []))
|
|
->merge(data_get($service, 'ports', []));
|
|
|
|
foreach ($portDefinitions as $definition) {
|
|
$protocol = is_array($definition)
|
|
? data_get($definition, 'protocol', 'tcp')
|
|
: (str_contains((string) $definition, '/') ? str((string) $definition)->afterLast('/')->value() : 'tcp');
|
|
if ($protocol !== 'tcp') {
|
|
continue;
|
|
}
|
|
|
|
$port = is_array($definition)
|
|
? data_get($definition, 'target')
|
|
: str((string) $definition)->before('/')->afterLast(':')->value();
|
|
|
|
if (is_numeric($port) && (int) $port >= 1 && (int) $port <= 65535) {
|
|
return (int) $port;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function dockerComposeServicePort(?string $compose, ?string $serviceName): ?int
|
|
{
|
|
return dockerComposeServicePorts($compose, $serviceName)[0] ?? null;
|
|
}
|
|
|
|
function dockerComposeServicePorts(?string $compose, ?string $serviceName): array
|
|
{
|
|
if (blank($compose) || blank($serviceName)) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
$services = data_get(parseDockerComposeYaml($compose), 'services', []);
|
|
} catch (Throwable) {
|
|
return [];
|
|
}
|
|
|
|
$service = is_array($services) ? ($services[$serviceName] ?? []) : [];
|
|
|
|
return collect(data_get($service, 'expose', []))
|
|
->merge(data_get($service, 'ports', []))
|
|
->map(fn ($definition) => firstDockerComposeServicePort(['expose' => [$definition]]))
|
|
->filter(fn ($port) => $port !== null)
|
|
->unique()->values()->all();
|
|
}
|
|
|
|
/**
|
|
* Traefik matches an IPv6 host without brackets: Host(`[2a01:4f8::1]`) never matches a request,
|
|
* Host(`2a01:4f8::1`) does. Labels that were saved with brackets get them removed here.
|
|
*/
|
|
function traefikHostRulesWithoutIpv6Brackets(Collection $labels): Collection
|
|
{
|
|
return $labels->map(fn ($label) => is_string($label) ? preg_replace_callback(
|
|
'/Host\(`\[([0-9A-Fa-f:.]+)\]`\)/',
|
|
fn (array $matches): string => filter_var($matches[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? "Host(`{$matches[1]}`)" : $matches[0],
|
|
$label,
|
|
) : $label);
|
|
}
|
|
|
|
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, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [], ?string $certificate_resolver = 'letsencrypt', ?int $http_basic_auth_bcrypt_cost = null, bool $is_permanent_redirect_enabled = false)
|
|
{
|
|
$labels = collect([]);
|
|
$labels->push('traefik.enable=true');
|
|
if ($is_gzip_enabled) {
|
|
// Not named `gzip`: containers that are not redeployed still declare the old `gzip`
|
|
// definition, and Traefik drops a middleware with conflicting definitions (#5076).
|
|
$labels->push('traefik.http.middlewares.gzip-sse.compress.excludedcontenttypes=text/event-stream');
|
|
}
|
|
$labels->push('traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https');
|
|
// Every container declares the shared middleware, so a permanent variant needs its own name.
|
|
$redirect_to_https_name = 'redirect-to-https';
|
|
if ($is_permanent_redirect_enabled) {
|
|
$redirect_to_https_name = 'redirect-to-https-permanent';
|
|
$labels->push('traefik.http.middlewares.redirect-to-https-permanent.redirectscheme.scheme=https');
|
|
$labels->push('traefik.http.middlewares.redirect-to-https-permanent.redirectscheme.permanent=true');
|
|
}
|
|
$redirect_permanent = $is_permanent_redirect_enabled ? 'true' : 'false';
|
|
|
|
$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' => $http_basic_auth_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_public_id();
|
|
}
|
|
|
|
$url = Url::fromString($domain);
|
|
$host = $url->getHost();
|
|
// Traefik matches an IPv6 Host header without brackets: Host(`[2a01:4f8::1]`) never matches.
|
|
$traefikHost = trim($host, '[]');
|
|
$path = $url->getPath();
|
|
$schema = $url->getScheme();
|
|
$portlessDomain = ServiceApplication::withoutPort($domain);
|
|
$port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null);
|
|
if (is_null($port) && ! is_null($onlyPort)) {
|
|
$port = $onlyPort;
|
|
}
|
|
$http_label = "http-{$loop}-{$uuid}";
|
|
$https_label = "https-{$loop}-{$uuid}";
|
|
if ($service_name) {
|
|
// Dots in service names split Traefik label paths; hash avoids api.test/api-test collisions.
|
|
$safeServiceName = traefikSafeServiceNameSegment($service_name);
|
|
$http_label = "http-{$loop}-{$uuid}-{$safeServiceName}";
|
|
$https_label = "https-{$loop}-{$uuid}-{$safeServiceName}";
|
|
}
|
|
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");
|
|
}
|
|
|
|
$noindex_name = "{$loop}-{$uuid}-noindex";
|
|
$is_noindex = isNoindexDomain($domain, $noindex_domains);
|
|
if ($is_noindex) {
|
|
$labels->push("traefik.http.middlewares.{$noindex_name}.headers.customresponseheaders.X-Robots-Tag=noindex, nofollow");
|
|
}
|
|
|
|
$to_www_name = "{$loop}-{$uuid}-to-www";
|
|
$to_non_www_name = "{$loop}-{$uuid}-to-non-www";
|
|
$redirect_capture_prefix = $escape_redirect_replacement_for_compose ? '$$' : '$';
|
|
$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={$redirect_capture_prefix}{1}://{$redirect_capture_prefix}{2}",
|
|
"traefik.http.middlewares.{$to_non_www_name}.redirectregex.permanent={$redirect_permanent}",
|
|
];
|
|
$redirect_to_www = [
|
|
"traefik.http.middlewares.{$to_www_name}.redirectregex.regex=^(http|https)://(?:www\.)?(.+)",
|
|
"traefik.http.middlewares.{$to_www_name}.redirectregex.replacement={$redirect_capture_prefix}{1}://www.{$redirect_capture_prefix}{2}",
|
|
"traefik.http.middlewares.{$to_www_name}.redirectregex.permanent={$redirect_permanent}",
|
|
];
|
|
if ($schema === 'https') {
|
|
// Set labels for https
|
|
$labels->push("traefik.http.routers.{$https_label}.rule=Host(`{$traefikHost}`) && 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-sse');
|
|
}
|
|
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);
|
|
}
|
|
if ($is_noindex) {
|
|
$middlewares->push($noindex_name);
|
|
}
|
|
$middlewares_from_labels->each(function ($middleware_name) use ($middlewares) {
|
|
$middlewares->push($middleware_name);
|
|
});
|
|
if ($middlewares->isNotEmpty()) {
|
|
$labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares->join(',')}");
|
|
}
|
|
} else {
|
|
$middlewares = collect([]);
|
|
if ($is_gzip_enabled) {
|
|
$middlewares->push('gzip-sse');
|
|
}
|
|
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);
|
|
}
|
|
if ($is_noindex) {
|
|
$middlewares->push($noindex_name);
|
|
}
|
|
$middlewares_from_labels->each(function ($middleware_name) use ($middlewares) {
|
|
$middlewares->push($middleware_name);
|
|
});
|
|
if ($middlewares->isNotEmpty()) {
|
|
$labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares->join(',')}");
|
|
}
|
|
}
|
|
$labels->push("traefik.http.routers.{$https_label}.tls=true");
|
|
if (filled($certificate_resolver)) {
|
|
$labels->push("traefik.http.routers.{$https_label}.tls.certresolver={$certificate_resolver}");
|
|
}
|
|
|
|
// Set labels for http (redirect to https)
|
|
$labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$traefikHost}`) && 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) {
|
|
$httpMiddlewares = collect([]);
|
|
if ($is_noindex) {
|
|
$httpMiddlewares->push($noindex_name);
|
|
}
|
|
$httpMiddlewares->push($redirect_to_https_name);
|
|
} else {
|
|
$httpMiddlewares = $middlewares;
|
|
}
|
|
if ($httpMiddlewares->isNotEmpty()) {
|
|
$labels->push("traefik.http.routers.{$http_label}.middlewares={$httpMiddlewares->join(',')}");
|
|
}
|
|
} else {
|
|
// Set labels for http
|
|
$labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$traefikHost}`) && 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-sse');
|
|
}
|
|
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);
|
|
}
|
|
if ($is_noindex) {
|
|
$middlewares->push($noindex_name);
|
|
}
|
|
$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-sse');
|
|
}
|
|
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);
|
|
}
|
|
if ($is_noindex) {
|
|
$middlewares->push($noindex_name);
|
|
}
|
|
$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([]);
|
|
$httpBasicAuthArgon2idOptions = $application->usesArgon2idBasicAuth() ? [
|
|
'memory_cost' => $application->http_basic_auth_argon2id_memory_cost,
|
|
'time_cost' => $application->http_basic_auth_argon2id_time_cost,
|
|
] : [];
|
|
if ($pull_request_id === 0) {
|
|
if ($application->fqdn) {
|
|
$domains = str(data_get($application, 'fqdn'))->explode(',');
|
|
$noindexDomains = $application->noindexDomains();
|
|
$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_permanent_redirect_enabled: $application->isPermanentRedirectEnabled(),
|
|
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,
|
|
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
|
noindex_domains: $noindexDomains,
|
|
domainPortOverrides: $application->domain_port_overrides ?? [],
|
|
certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver),
|
|
));
|
|
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_permanent_redirect_enabled: $application->isPermanentRedirectEnabled(),
|
|
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,
|
|
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
|
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
|
|
noindex_domains: $noindexDomains,
|
|
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
|
|
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
|
|
supports_basic_auth_directive: $application->destination->server->caddySupportsBasicAuthDirective(),
|
|
domainPortOverrides: $application->domain_port_overrides ?? [],
|
|
));
|
|
break;
|
|
}
|
|
} else {
|
|
$labels = $labels->merge(fqdnLabelsForTraefik(
|
|
uuid: $appUuid,
|
|
domains: $domains,
|
|
onlyPort: $onlyPort,
|
|
is_force_https_enabled: $application->isForceHttpsEnabled(),
|
|
is_permanent_redirect_enabled: $application->isPermanentRedirectEnabled(),
|
|
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,
|
|
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
|
noindex_domains: $noindexDomains,
|
|
escape_redirect_replacement_for_compose: false,
|
|
domainPortOverrides: $application->domain_port_overrides ?? [],
|
|
certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver),
|
|
));
|
|
$labels = $labels->merge(fqdnLabelsForCaddy(
|
|
network: $application->destination->network,
|
|
uuid: $appUuid,
|
|
domains: $domains,
|
|
onlyPort: $onlyPort,
|
|
is_force_https_enabled: $application->isForceHttpsEnabled(),
|
|
is_permanent_redirect_enabled: $application->isPermanentRedirectEnabled(),
|
|
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,
|
|
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
|
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
|
|
noindex_domains: $noindexDomains,
|
|
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
|
|
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
|
|
supports_basic_auth_directive: $application->destination->server->caddySupportsBasicAuthDirective(),
|
|
domainPortOverrides: $application->domain_port_overrides ?? [],
|
|
));
|
|
}
|
|
}
|
|
} else {
|
|
if (data_get($preview, 'fqdn')) {
|
|
$domains = str(data_get($preview, 'fqdn'))->explode(',');
|
|
} else {
|
|
$domains = collect([]);
|
|
}
|
|
// Previews are ephemeral: every domain is noindex.
|
|
$noindexDomains = $domains;
|
|
$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_permanent_redirect_enabled: $application->isPermanentRedirectEnabled(),
|
|
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,
|
|
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
|
noindex_domains: $noindexDomains,
|
|
escape_redirect_replacement_for_compose: false,
|
|
domainPortOverrides: $preview->domain_port_overrides ?? [],
|
|
certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver),
|
|
));
|
|
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_permanent_redirect_enabled: $application->isPermanentRedirectEnabled(),
|
|
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,
|
|
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
|
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
|
|
noindex_domains: $noindexDomains,
|
|
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
|
|
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
|
|
supports_basic_auth_directive: $application->destination->server->caddySupportsBasicAuthDirective(),
|
|
domainPortOverrides: $preview->domain_port_overrides ?? [],
|
|
));
|
|
break;
|
|
}
|
|
} else {
|
|
$labels = $labels->merge(fqdnLabelsForTraefik(
|
|
uuid: $appUuid,
|
|
domains: $domains,
|
|
onlyPort: $onlyPort,
|
|
is_force_https_enabled: $application->isForceHttpsEnabled(),
|
|
is_permanent_redirect_enabled: $application->isPermanentRedirectEnabled(),
|
|
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,
|
|
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
|
noindex_domains: $noindexDomains,
|
|
escape_redirect_replacement_for_compose: false,
|
|
domainPortOverrides: $preview->domain_port_overrides ?? [],
|
|
certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver),
|
|
));
|
|
$labels = $labels->merge(fqdnLabelsForCaddy(
|
|
network: $application->destination->network,
|
|
uuid: $appUuid,
|
|
domains: $domains,
|
|
onlyPort: $onlyPort,
|
|
is_force_https_enabled: $application->isForceHttpsEnabled(),
|
|
is_permanent_redirect_enabled: $application->isPermanentRedirectEnabled(),
|
|
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,
|
|
http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost,
|
|
http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions,
|
|
noindex_domains: $noindexDomains,
|
|
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
|
|
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
|
|
supports_basic_auth_directive: $application->destination->server->caddySupportsBasicAuthDirective(),
|
|
domainPortOverrides: $preview->domain_port_overrides ?? [],
|
|
));
|
|
}
|
|
}
|
|
|
|
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([]);
|
|
// A value is one shell word: double-quoted (with escapes), single-quoted, and unquoted parts, as in key="a b".
|
|
// It cannot start with "-" (except a negative number, as in --pids-limit -1), so the next flag is not taken as a value.
|
|
preg_match_all('/(--\w+(?:-\w+)*)(?:\s+|=)?((?!-(?!\d))(?:"(?:\\\\.|[^"])*"|\'[^\']*\'|[^\s"\']|["\'])+)?/', $custom_docker_run_options, $matches, PREG_SET_ORDER);
|
|
$list_options = collect([
|
|
'--add-host',
|
|
'--cap-add',
|
|
'--cap-drop',
|
|
'--security-opt',
|
|
'--sysctl',
|
|
'--ulimit',
|
|
'--device',
|
|
'--shm-size',
|
|
'--dns',
|
|
'--tmpfs',
|
|
]);
|
|
$mapping = collect(DOCKER_RUN_OPTION_MAPPING);
|
|
foreach ($matches as $match) {
|
|
$option = $match[1];
|
|
// Remove the quotes like a shell: unescape \" \$ \` \\ in double quotes, keep single-quoted text as is
|
|
$value = preg_replace_callback(
|
|
'/"((?:\\\\.|[^"])*)"|\'([^\']*)\'/',
|
|
fn ($quoted) => isset($quoted[2]) ? $quoted[2] : preg_replace('/\\\\(["$`\\\\])/', '$1', $quoted[1]),
|
|
$match[2] ?? ''
|
|
);
|
|
if ($option === '--gpus') {
|
|
// all, a count (2), or device IDs ("device=0,1"; the inner quotes are part of the Docker syntax)
|
|
$gpus = trim($value, '"');
|
|
$payload = [
|
|
'driver' => 'nvidia',
|
|
'capabilities' => ['gpu'],
|
|
];
|
|
if (preg_match('/^device=([0-9A-Za-z-,]+)$/', $gpus, $device_matches)) {
|
|
$payload['device_ids'] = explode(',', $device_matches[1]);
|
|
} elseif (ctype_digit($gpus)) {
|
|
$payload['count'] = (int) $gpus;
|
|
}
|
|
$options[$option] = $payload;
|
|
|
|
continue;
|
|
}
|
|
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 (in_array($option, ['--init', '--privileged', '--read-only'], true)) {
|
|
// Boolean flags only take a value with "=" (--privileged=false); a space-separated word is not their value
|
|
$hasInlineValue = str_starts_with(substr($match[0], strlen($option)), '=');
|
|
$options[$option] = ! ($hasInlineValue && strtolower($value) === 'false');
|
|
|
|
continue;
|
|
}
|
|
if (trim($value) === '') {
|
|
continue;
|
|
}
|
|
$options[$option][] = $value;
|
|
$options[$option] = array_unique($options[$option]);
|
|
}
|
|
$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) {
|
|
if (! str_contains($ulimit, '=')) {
|
|
return;
|
|
}
|
|
$ulimit = explode('=', $ulimit, 2);
|
|
$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 {
|
|
// Compose needs both soft and hard in the mapping form, so a single limit is set directly
|
|
$ulimits->put($type, $ulimit[1]);
|
|
}
|
|
});
|
|
if ($ulimits->isNotEmpty()) {
|
|
$compose_options->put($mapping[$option], $ulimits);
|
|
}
|
|
} elseif (in_array($option, ['--shm-size', '--hostname', '--runtime'], true)) {
|
|
if (! is_null($value) && is_array($value) && count($value) > 0 && ! empty(trim($value[0]))) {
|
|
$compose_options->put($mapping[$option], $value[0]);
|
|
}
|
|
} elseif ($option === '--pids-limit') {
|
|
if (is_numeric($value[0])) {
|
|
$compose_options->put($mapping[$option], (int) $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') {
|
|
$compose_options->put('deploy', [
|
|
'resources' => [
|
|
'reservations' => [
|
|
'devices' => [$value],
|
|
],
|
|
],
|
|
]);
|
|
} 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Short interactive/TTY flags (-i, -t, -it, -ti) and their long forms are not matched by
|
|
// the long-option regex above. Map them to compose so a single-image app can keep STDIN
|
|
// open (docker -i) and/or allocate a TTY (docker -t) — required for `docker attach` to an
|
|
// interactive main process, e.g. game-server consoles.
|
|
// Keep quoted option values together so flags inside an entrypoint are not options.
|
|
preg_match_all('/(?:[^\\s\'"\\\\]+|\\\\.|"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')+/', (string) $custom_docker_run_options, $tokens);
|
|
foreach ($tokens[0] as $token) {
|
|
if ($token === '--interactive') {
|
|
$compose_options->put('stdin_open', true);
|
|
} elseif ($token === '--tty') {
|
|
$compose_options->put('tty', true);
|
|
} elseif (preg_match('/^-[dit]+$/', $token)) {
|
|
if (str_contains($token, 'i')) {
|
|
$compose_options->put('stdin_open', true);
|
|
}
|
|
if (str_contains($token, 't')) {
|
|
$compose_options->put('tty', true);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $compose_options->toArray();
|
|
}
|
|
|
|
/**
|
|
* Returns the options (--network, -e, ...) that convertDockerRunToCompose() does not apply.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
function unsupportedDockerRunOptions(?string $custom_docker_run_options): array
|
|
{
|
|
// Split into shell words, so text inside a quoted value is not taken as an option
|
|
preg_match_all('/(?:"(?:\\\\.|[^"])*"|\'[^\']*\'|[^\s"\']|["\'])+/', $custom_docker_run_options ?? '', $words);
|
|
$unsupported = [];
|
|
foreach ($words[0] as $word) {
|
|
if (! str_starts_with($word, '-') || preg_match('/^-\d/', $word)) {
|
|
continue;
|
|
}
|
|
// convertDockerRunToCompose() maps these outside DOCKER_RUN_OPTION_MAPPING
|
|
if (preg_match('/^(-[dit]+|--interactive|--tty)$/', $word)) {
|
|
continue;
|
|
}
|
|
$option = str_starts_with($word, '--') ? '--'.ltrim(strtok($word, '='), '-') : strtok($word, '=');
|
|
if (! array_key_exists($option, DOCKER_RUN_OPTION_MAPPING)) {
|
|
$unsupported[] = $option;
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($unsupported));
|
|
}
|
|
|
|
/**
|
|
* Returns --mount flags that make seccomp profile files from the custom Docker options readable in the deployment
|
|
* helper container, where docker compose reads them. The path stays the same, so security_opt works unchanged.
|
|
*/
|
|
function seccompProfileMountFlags(?string $custom_docker_run_options): string
|
|
{
|
|
$flags = [];
|
|
foreach (data_get(convertDockerRunToCompose($custom_docker_run_options), 'security_opt', []) as $security_opt) {
|
|
if (preg_match('#^seccomp[=:](/[A-Za-z0-9._/-]+)$#', $security_opt, $profile) && ! str_contains($profile[1], '..')) {
|
|
$flags[] = '--mount '.escapeshellarg("type=bind,source={$profile[1]},target={$profile[1]},readonly");
|
|
}
|
|
}
|
|
|
|
return implode(' ', array_unique($flags));
|
|
}
|
|
|
|
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;
|
|
}
|
|
// A custom ulimit replaces the default of the same type; a recursive merge would combine both into an invalid value
|
|
foreach (array_keys(data_get($docker_run_options, 'ulimits', [])) as $type) {
|
|
unset($docker_compose['services'][$container_name]['ulimits'][$type]);
|
|
}
|
|
$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 = parseDockerComposeYaml($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 normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int|string
|
|
{
|
|
if ($lines === 'all') {
|
|
return 'all';
|
|
}
|
|
|
|
$lines = filter_var($lines, FILTER_VALIDATE_INT);
|
|
if ($lines === -1) {
|
|
return 'all';
|
|
}
|
|
if ($lines === false || $lines < -1) {
|
|
return $default;
|
|
}
|
|
|
|
return min($lines, $max);
|
|
}
|
|
|
|
function parseLogTimestampFlag(mixed $showTimestamps): bool
|
|
{
|
|
return filter_var($showTimestamps, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
|
|
}
|
|
|
|
function buildContainerLogsCommand(Server $server, string $container_id, int|string $lines = 100, bool $showTimestamps = false): string
|
|
{
|
|
$command = "docker logs -n {$lines}";
|
|
if ($server->isSwarm()) {
|
|
$command = "docker service logs -n {$lines}";
|
|
}
|
|
|
|
if ($showTimestamps) {
|
|
$command .= ' --timestamps';
|
|
}
|
|
|
|
return "{$command} ".escapeshellarg($container_id).' 2>&1';
|
|
}
|
|
|
|
function getContainerLogs(Server $server, string $container_id, int|string $lines = 100, bool $showTimestamps = false): string
|
|
{
|
|
$output = instant_remote_process([buildContainerLogsCommand($server, $container_id, $lines, $showTimestamps)], $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}'";
|
|
}
|
|
|
|
function escapeComposeEnvFileValue(?string $value, bool $allowInterpolation = false): string
|
|
{
|
|
$escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value ?? '');
|
|
|
|
if (! $allowInterpolation) {
|
|
$escaped = str_replace('$', '\\$', $escaped);
|
|
}
|
|
|
|
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 Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
|
|
* @return Collection Collection of formatted --build-arg strings (keys only)
|
|
*/
|
|
function generateDockerBuildArgs($variables): Collection
|
|
{
|
|
$variables = collect($variables);
|
|
|
|
return $variables->map(function ($var) {
|
|
$key = is_array($var) ? data_get($var, 'key') : $var->key;
|
|
|
|
if (! ValidationPatterns::isValidEnvironmentVariableKey((string) $key)) {
|
|
throw new InvalidArgumentException('Invalid environment variable key.');
|
|
}
|
|
|
|
// Only return the key - Docker will get the value from the environment
|
|
return '--build-arg '.escapeshellarg((string) $key);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Generate Docker environment flags from environment variables collection
|
|
*
|
|
* @param Collection|array $variables Collection of variables with 'key' and 'value'
|
|
* @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;
|
|
|
|
if (! ValidationPatterns::isValidEnvironmentVariableKey((string) $key)) {
|
|
throw new InvalidArgumentException('Invalid environment variable key.');
|
|
}
|
|
|
|
return '-e '.escapeshellarg("{$key}={$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;
|
|
}
|
|
|
|
/**
|
|
* Run a command that uses the Railpack BuildKit builder, then stop the builder when no other
|
|
* process uses it. An idle buildkitd keeps the memory of earlier builds until it restarts.
|
|
*
|
|
* The lock file is in the buildx metadata directory, which all helper containers of a server share.
|
|
* The script has no `&&`, `||`, pipe, or `$(`, because the sudo parser would put sudo into it.
|
|
*/
|
|
function railpackBuilderLockedScript(string $command): string
|
|
{
|
|
return 'exec 9>/root/.docker/buildx/coolify-railpack.lock; if flock -s 9; then :; else exit $?; fi; '
|
|
."{$command}; status=\$?; "
|
|
.'if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack >/dev/null 2>&1; fi; '
|
|
.'exit $status';
|
|
}
|
|
|
|
/**
|
|
* Start the Railpack builder and wait until buildkitd accepts connections. Run it inside
|
|
* railpackBuilderLockedScript(), so that no other process stops the builder after it starts.
|
|
*
|
|
* buildx waits for buildkitd only when it starts the builder itself. A concurrent build that sees
|
|
* the container already running connects at once and fails while the BuildKit socket is missing.
|
|
* The lock makes the other builds wait until the first bootstrap is complete.
|
|
*/
|
|
function railpackBuilderBootstrapScript(): string
|
|
{
|
|
return 'exec 8>/root/.docker/buildx/coolify-railpack-bootstrap.lock; flock -x 8; '
|
|
.'DOCKER_CONFIG=/root/.docker docker buildx inspect --bootstrap coolify-railpack >/dev/null 2>&1; '
|
|
.'flock -u 8';
|
|
}
|
|
|
|
/**
|
|
* The buildx metadata of a server, as the `docker run -v` source that the deployment helper uses.
|
|
*/
|
|
function railpackBuildxMetadataVolume(Server $server): string
|
|
{
|
|
return isDev() && $server->isLocalhost() ? 'coolify-buildx' : '$HOME/.docker/buildx';
|
|
}
|
|
|
|
/**
|
|
* Run a command for the Railpack builder from a temporary helper container, then stop the builder
|
|
* when no build uses it.
|
|
*/
|
|
function railpackBuilderHelperCommand(string $buildxMetadataVolume, string $helperImage, string $command): string
|
|
{
|
|
$script = railpackBuilderLockedScript($command);
|
|
|
|
return "docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImage} bash -c '{$script}' 2>/dev/null || true";
|
|
}
|