When catching and re-throwing exceptions, preserve the original exception
chain by passing the caught exception as the third parameter to new Exception.
This retains the full stack trace for debugging while keeping descriptive
error messages.
Changes:
- validateDockerComposeForInjection(): 4 locations fixed
- validateVolumeStringForInjection(): 3 locations fixed
Before:
throw new \Exception('Invalid Docker volume definition: '.$e->getMessage());
After:
throw new \Exception('Invalid Docker volume definition: '.$e->getMessage(), 0, $e);
Benefits:
- Full stack trace preserved for debugging
- Original exception context retained
- Better error diagnostics in production logs
All 60 security tests pass (176 assertions).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2271 lines
102 KiB
PHP
2271 lines
102 KiB
PHP
<?php
|
|
|
|
use App\Enums\ProxyTypes;
|
|
use App\Jobs\ServerFilesFromServerJob;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationPreview;
|
|
use App\Models\LocalFileVolume;
|
|
use App\Models\LocalPersistentVolume;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceApplication;
|
|
use App\Models\ServiceDatabase;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Str;
|
|
use Spatie\Url\Url;
|
|
use Symfony\Component\Yaml\Yaml;
|
|
use Visus\Cuid2\Cuid2;
|
|
|
|
/**
|
|
* Validates a Docker Compose YAML string for command injection vulnerabilities.
|
|
* This should be called BEFORE saving to database to prevent malicious data from being stored.
|
|
*
|
|
* @param string $composeYaml The raw Docker Compose YAML content
|
|
*
|
|
* @throws \Exception If the compose file contains command injection attempts
|
|
*/
|
|
function validateDockerComposeForInjection(string $composeYaml): void
|
|
{
|
|
try {
|
|
$parsed = Yaml::parse($composeYaml);
|
|
} catch (\Exception $e) {
|
|
throw new \Exception('Invalid YAML format: '.$e->getMessage(), 0, $e);
|
|
}
|
|
|
|
if (! is_array($parsed) || ! isset($parsed['services']) || ! is_array($parsed['services'])) {
|
|
throw new \Exception('Docker Compose file must contain a "services" section');
|
|
}
|
|
// Validate service names
|
|
foreach ($parsed['services'] as $serviceName => $serviceConfig) {
|
|
try {
|
|
validateShellSafePath($serviceName, 'service name');
|
|
} catch (\Exception $e) {
|
|
throw new \Exception(
|
|
'Invalid Docker Compose service name: '.$e->getMessage().
|
|
' Service names must not contain shell metacharacters.',
|
|
0,
|
|
$e
|
|
);
|
|
}
|
|
|
|
// Validate volumes in this service (both string and array formats)
|
|
if (isset($serviceConfig['volumes']) && is_array($serviceConfig['volumes'])) {
|
|
foreach ($serviceConfig['volumes'] as $volume) {
|
|
if (is_string($volume)) {
|
|
// String format: "source:target" or "source:target:mode"
|
|
validateVolumeStringForInjection($volume);
|
|
} elseif (is_array($volume)) {
|
|
// Array format: {type: bind, source: ..., target: ...}
|
|
if (isset($volume['source'])) {
|
|
$source = $volume['source'];
|
|
if (is_string($source)) {
|
|
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $source);
|
|
if (! $isSimpleEnvVar) {
|
|
try {
|
|
validateShellSafePath($source, 'volume source');
|
|
} catch (\Exception $e) {
|
|
throw new \Exception(
|
|
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
|
|
' Please use safe path names without shell metacharacters.',
|
|
0,
|
|
$e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (isset($volume['target'])) {
|
|
$target = $volume['target'];
|
|
if (is_string($target)) {
|
|
try {
|
|
validateShellSafePath($target, 'volume target');
|
|
} catch (\Exception $e) {
|
|
throw new \Exception(
|
|
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
|
|
' Please use safe path names without shell metacharacters.',
|
|
0,
|
|
$e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates a Docker volume string (format: "source:target" or "source:target:mode")
|
|
*
|
|
* @param string $volumeString The volume string to validate
|
|
*
|
|
* @throws \Exception If the volume string contains command injection attempts
|
|
*/
|
|
function validateVolumeStringForInjection(string $volumeString): void
|
|
{
|
|
// Parse the volume string to extract source and target
|
|
$parts = explode(':', $volumeString);
|
|
if (count($parts) < 2) {
|
|
// Named volume without target - only validate the name
|
|
try {
|
|
validateShellSafePath($parts[0], 'volume name');
|
|
} catch (\Exception $e) {
|
|
throw new \Exception(
|
|
'Invalid Docker volume definition: '.$e->getMessage().
|
|
' Please use safe names without shell metacharacters.',
|
|
0,
|
|
$e
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
$source = $parts[0];
|
|
$target = $parts[1];
|
|
|
|
// Validate source (but allow simple environment variables)
|
|
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $source);
|
|
if (! $isSimpleEnvVar) {
|
|
try {
|
|
validateShellSafePath($source, 'volume source');
|
|
} catch (\Exception $e) {
|
|
throw new \Exception(
|
|
'Invalid Docker volume definition: '.$e->getMessage().
|
|
' Please use safe path names without shell metacharacters.',
|
|
0,
|
|
$e
|
|
);
|
|
}
|
|
}
|
|
|
|
// Validate target
|
|
try {
|
|
validateShellSafePath($target, 'volume target');
|
|
} catch (\Exception $e) {
|
|
throw new \Exception(
|
|
'Invalid Docker volume definition: '.$e->getMessage().
|
|
' Please use safe path names without shell metacharacters.',
|
|
0,
|
|
$e
|
|
);
|
|
}
|
|
}
|
|
|
|
function parseDockerVolumeString(string $volumeString): array
|
|
{
|
|
$volumeString = trim($volumeString);
|
|
$source = null;
|
|
$target = null;
|
|
$mode = null;
|
|
|
|
// First, check if the source contains an environment variable with default value
|
|
// This needs to be done before counting colons because ${VAR:-value} contains a colon
|
|
$envVarPattern = '/^\$\{[^}]+:-[^}]*\}/';
|
|
$hasEnvVarWithDefault = false;
|
|
$envVarEndPos = 0;
|
|
|
|
if (preg_match($envVarPattern, $volumeString, $matches)) {
|
|
$hasEnvVarWithDefault = true;
|
|
$envVarEndPos = strlen($matches[0]);
|
|
}
|
|
|
|
// Count colons, but exclude those inside environment variables
|
|
$effectiveVolumeString = $volumeString;
|
|
if ($hasEnvVarWithDefault) {
|
|
// Temporarily replace the env var to count colons correctly
|
|
$effectiveVolumeString = substr($volumeString, $envVarEndPos);
|
|
$colonCount = substr_count($effectiveVolumeString, ':');
|
|
} else {
|
|
$colonCount = substr_count($volumeString, ':');
|
|
}
|
|
|
|
if ($colonCount === 0) {
|
|
// Named volume without target (unusual but valid)
|
|
// Example: "myvolume"
|
|
$source = $volumeString;
|
|
$target = $volumeString;
|
|
} elseif ($colonCount === 1) {
|
|
// Simple volume mapping
|
|
// Examples: "gitea:/data" or "./data:/app/data" or "${VAR:-default}:/data"
|
|
if ($hasEnvVarWithDefault) {
|
|
$source = substr($volumeString, 0, $envVarEndPos);
|
|
$remaining = substr($volumeString, $envVarEndPos);
|
|
if (strlen($remaining) > 0 && $remaining[0] === ':') {
|
|
$target = substr($remaining, 1);
|
|
} else {
|
|
$target = $remaining;
|
|
}
|
|
} else {
|
|
$parts = explode(':', $volumeString);
|
|
$source = $parts[0];
|
|
$target = $parts[1];
|
|
}
|
|
} elseif ($colonCount === 2) {
|
|
// Volume with mode OR Windows path OR env var with mode
|
|
// Handle env var with mode first
|
|
if ($hasEnvVarWithDefault) {
|
|
// ${VAR:-default}:/path:mode
|
|
$source = substr($volumeString, 0, $envVarEndPos);
|
|
$remaining = substr($volumeString, $envVarEndPos);
|
|
|
|
if (strlen($remaining) > 0 && $remaining[0] === ':') {
|
|
$remaining = substr($remaining, 1);
|
|
$lastColon = strrpos($remaining, ':');
|
|
|
|
if ($lastColon !== false) {
|
|
$possibleMode = substr($remaining, $lastColon + 1);
|
|
$validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
|
|
|
|
if (in_array($possibleMode, $validModes)) {
|
|
$mode = $possibleMode;
|
|
$target = substr($remaining, 0, $lastColon);
|
|
} else {
|
|
$target = $remaining;
|
|
}
|
|
} else {
|
|
$target = $remaining;
|
|
}
|
|
}
|
|
} elseif (preg_match('/^[A-Za-z]:/', $volumeString)) {
|
|
// Windows path as source (C:/, D:/, etc.)
|
|
// Find the second colon which is the real separator
|
|
$secondColon = strpos($volumeString, ':', 2);
|
|
if ($secondColon !== false) {
|
|
$source = substr($volumeString, 0, $secondColon);
|
|
$target = substr($volumeString, $secondColon + 1);
|
|
} else {
|
|
// Malformed, treat as is
|
|
$source = $volumeString;
|
|
$target = $volumeString;
|
|
}
|
|
} else {
|
|
// Not a Windows path, check for mode
|
|
$lastColon = strrpos($volumeString, ':');
|
|
$possibleMode = substr($volumeString, $lastColon + 1);
|
|
|
|
// Check if the last part is a valid Docker volume mode
|
|
$validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
|
|
|
|
if (in_array($possibleMode, $validModes)) {
|
|
// It's a mode
|
|
// Examples: "gitea:/data:ro" or "./data:/app/data:rw"
|
|
$mode = $possibleMode;
|
|
$volumeWithoutMode = substr($volumeString, 0, $lastColon);
|
|
$colonPos = strpos($volumeWithoutMode, ':');
|
|
|
|
if ($colonPos !== false) {
|
|
$source = substr($volumeWithoutMode, 0, $colonPos);
|
|
$target = substr($volumeWithoutMode, $colonPos + 1);
|
|
} else {
|
|
// Shouldn't happen for valid volume strings
|
|
$source = $volumeWithoutMode;
|
|
$target = $volumeWithoutMode;
|
|
}
|
|
} else {
|
|
// The last colon is part of the path
|
|
// For now, treat the first occurrence of : as the separator
|
|
$firstColon = strpos($volumeString, ':');
|
|
$source = substr($volumeString, 0, $firstColon);
|
|
$target = substr($volumeString, $firstColon + 1);
|
|
}
|
|
}
|
|
} else {
|
|
// More than 2 colons - likely Windows paths or complex cases
|
|
// Use a heuristic: find the most likely separator colon
|
|
// Look for patterns like "C:" at the beginning (Windows drive)
|
|
if (preg_match('/^[A-Za-z]:/', $volumeString)) {
|
|
// Windows path as source
|
|
// Find the next colon after the drive letter
|
|
$secondColon = strpos($volumeString, ':', 2);
|
|
if ($secondColon !== false) {
|
|
$source = substr($volumeString, 0, $secondColon);
|
|
$remaining = substr($volumeString, $secondColon + 1);
|
|
|
|
// Check if there's a mode at the end
|
|
$lastColon = strrpos($remaining, ':');
|
|
if ($lastColon !== false) {
|
|
$possibleMode = substr($remaining, $lastColon + 1);
|
|
$validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
|
|
|
|
if (in_array($possibleMode, $validModes)) {
|
|
$mode = $possibleMode;
|
|
$target = substr($remaining, 0, $lastColon);
|
|
} else {
|
|
$target = $remaining;
|
|
}
|
|
} else {
|
|
$target = $remaining;
|
|
}
|
|
} else {
|
|
// Malformed, treat as is
|
|
$source = $volumeString;
|
|
$target = $volumeString;
|
|
}
|
|
} else {
|
|
// Try to parse normally, treating first : as separator
|
|
$firstColon = strpos($volumeString, ':');
|
|
$source = substr($volumeString, 0, $firstColon);
|
|
$remaining = substr($volumeString, $firstColon + 1);
|
|
|
|
// Check for mode at the end
|
|
$lastColon = strrpos($remaining, ':');
|
|
if ($lastColon !== false) {
|
|
$possibleMode = substr($remaining, $lastColon + 1);
|
|
$validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
|
|
|
|
if (in_array($possibleMode, $validModes)) {
|
|
$mode = $possibleMode;
|
|
$target = substr($remaining, 0, $lastColon);
|
|
} else {
|
|
$target = $remaining;
|
|
}
|
|
} else {
|
|
$target = $remaining;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle environment variable expansion in source
|
|
// Example: ${VOLUME_DB_PATH:-db} should extract default value if present
|
|
if ($source && preg_match('/^\$\{([^}]+)\}$/', $source, $matches)) {
|
|
$varContent = $matches[1];
|
|
|
|
// Check if there's a default value with :-
|
|
if (strpos($varContent, ':-') !== false) {
|
|
$parts = explode(':-', $varContent, 2);
|
|
$varName = $parts[0];
|
|
$defaultValue = isset($parts[1]) ? $parts[1] : '';
|
|
|
|
// If there's a non-empty default value, use it for source
|
|
if ($defaultValue !== '') {
|
|
$source = $defaultValue;
|
|
} else {
|
|
// Empty default value, keep the variable reference for env resolution
|
|
$source = '${'.$varName.'}';
|
|
}
|
|
}
|
|
// Otherwise keep the variable as-is for later expansion (no default value)
|
|
}
|
|
|
|
// Validate source path for command injection attempts
|
|
// We validate the final source value after environment variable processing
|
|
if ($source !== null) {
|
|
// Allow simple environment variables like ${VAR_NAME} or ${VAR}
|
|
// but validate everything else for shell metacharacters
|
|
$sourceStr = is_string($source) ? $source : $source;
|
|
|
|
// Skip validation for simple environment variable references
|
|
// Pattern: ${WORD_CHARS} with no special characters inside
|
|
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceStr);
|
|
|
|
if (! $isSimpleEnvVar) {
|
|
try {
|
|
validateShellSafePath($sourceStr, 'volume source');
|
|
} catch (\Exception $e) {
|
|
// Re-throw with more context about the volume string
|
|
throw new \Exception(
|
|
'Invalid Docker volume definition: '.$e->getMessage().
|
|
' Please use safe path names without shell metacharacters.'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also validate target path
|
|
if ($target !== null) {
|
|
$targetStr = is_string($target) ? $target : $target;
|
|
// Target paths in containers are typically absolute paths, so we validate them too
|
|
// but they're less likely to be dangerous since they're not used in host commands
|
|
// Still, defense in depth is important
|
|
try {
|
|
validateShellSafePath($targetStr, 'volume target');
|
|
} catch (\Exception $e) {
|
|
throw new \Exception(
|
|
'Invalid Docker volume definition: '.$e->getMessage().
|
|
' Please use safe path names without shell metacharacters.'
|
|
);
|
|
}
|
|
}
|
|
|
|
return [
|
|
'source' => $source !== null ? str($source) : null,
|
|
'target' => $target !== null ? str($target) : null,
|
|
'mode' => $mode !== null ? str($mode) : null,
|
|
];
|
|
}
|
|
|
|
function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null): Collection
|
|
{
|
|
$uuid = data_get($resource, 'uuid');
|
|
$compose = data_get($resource, 'docker_compose_raw');
|
|
if (! $compose) {
|
|
return collect([]);
|
|
}
|
|
|
|
$pullRequestId = $pull_request_id;
|
|
$isPullRequest = $pullRequestId == 0 ? false : true;
|
|
$server = data_get($resource, 'destination.server');
|
|
$fileStorages = $resource->fileStorages();
|
|
|
|
try {
|
|
$yaml = Yaml::parse($compose);
|
|
} catch (\Exception) {
|
|
return collect([]);
|
|
}
|
|
$services = data_get($yaml, 'services', collect([]));
|
|
$topLevel = collect([
|
|
'volumes' => collect(data_get($yaml, 'volumes', [])),
|
|
'networks' => collect(data_get($yaml, 'networks', [])),
|
|
'configs' => collect(data_get($yaml, 'configs', [])),
|
|
'secrets' => collect(data_get($yaml, 'secrets', [])),
|
|
]);
|
|
// If there are predefined volumes, make sure they are not null
|
|
if ($topLevel->get('volumes')->count() > 0) {
|
|
$temp = collect([]);
|
|
foreach ($topLevel['volumes'] as $volumeName => $volume) {
|
|
if (is_null($volume)) {
|
|
continue;
|
|
}
|
|
$temp->put($volumeName, $volume);
|
|
}
|
|
$topLevel['volumes'] = $temp;
|
|
}
|
|
// Get the base docker network
|
|
$baseNetwork = collect([$uuid]);
|
|
if ($isPullRequest) {
|
|
$baseNetwork = collect(["{$uuid}-{$pullRequestId}"]);
|
|
}
|
|
|
|
$parsedServices = collect([]);
|
|
|
|
$allMagicEnvironments = collect([]);
|
|
foreach ($services as $serviceName => $service) {
|
|
// Validate service name for command injection
|
|
try {
|
|
validateShellSafePath($serviceName, 'service name');
|
|
} catch (\Exception $e) {
|
|
throw new \Exception(
|
|
'Invalid Docker Compose service name: '.$e->getMessage().
|
|
' Service names must not contain shell metacharacters.'
|
|
);
|
|
}
|
|
|
|
$magicEnvironments = collect([]);
|
|
$image = data_get_str($service, 'image');
|
|
$environment = collect(data_get($service, 'environment', []));
|
|
$buildArgs = collect(data_get($service, 'build.args', []));
|
|
$environment = $environment->merge($buildArgs);
|
|
|
|
$environment = collect(data_get($service, 'environment', []));
|
|
$buildArgs = collect(data_get($service, 'build.args', []));
|
|
$environment = $environment->merge($buildArgs);
|
|
|
|
// convert environment variables to one format
|
|
$environment = convertToKeyValueCollection($environment);
|
|
|
|
// Add Coolify defined environments
|
|
$allEnvironments = $resource->environment_variables()->get(['key', 'value']);
|
|
|
|
$allEnvironments = $allEnvironments->mapWithKeys(function ($item) {
|
|
return [$item['key'] => $item['value']];
|
|
});
|
|
// filter and add magic environments
|
|
foreach ($environment as $key => $value) {
|
|
// Get all SERVICE_ variables from keys and values
|
|
$key = str($key);
|
|
$value = str($value);
|
|
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
|
|
preg_match_all($regex, $value, $valueMatches);
|
|
if (count($valueMatches[1]) > 0) {
|
|
foreach ($valueMatches[1] as $match) {
|
|
$match = replaceVariables($match);
|
|
if ($match->startsWith('SERVICE_')) {
|
|
if ($magicEnvironments->has($match->value())) {
|
|
continue;
|
|
}
|
|
$magicEnvironments->put($match->value(), '');
|
|
}
|
|
}
|
|
}
|
|
// Get magic environments where we need to preset the FQDN
|
|
// for example SERVICE_FQDN_APP_3000 (without a value)
|
|
if ($key->startsWith('SERVICE_FQDN_')) {
|
|
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
|
|
if (substr_count(str($key)->value(), '_') === 3) {
|
|
$fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
|
|
$port = $key->afterLast('_')->value();
|
|
} else {
|
|
$fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
|
|
$port = null;
|
|
}
|
|
$fqdn = $resource->fqdn;
|
|
if (blank($resource->fqdn)) {
|
|
$fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version);
|
|
}
|
|
|
|
if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
|
|
$path = $value->value();
|
|
if ($path !== '/') {
|
|
$fqdn = "$fqdn$path";
|
|
}
|
|
}
|
|
$fqdnWithPort = $fqdn;
|
|
if ($port) {
|
|
$fqdnWithPort = "$fqdn:$port";
|
|
}
|
|
if (is_null($resource->fqdn)) {
|
|
data_forget($resource, 'environment_variables');
|
|
data_forget($resource, 'environment_variables_preview');
|
|
$resource->fqdn = $fqdnWithPort;
|
|
$resource->save();
|
|
}
|
|
|
|
if (substr_count(str($key)->value(), '_') === 2) {
|
|
$resource->environment_variables()->updateOrCreate([
|
|
'key' => $key->value(),
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $fqdn,
|
|
'is_preview' => false,
|
|
]);
|
|
}
|
|
if (substr_count(str($key)->value(), '_') === 3) {
|
|
|
|
$newKey = str($key)->beforeLast('_');
|
|
$resource->environment_variables()->updateOrCreate([
|
|
'key' => $newKey->value(),
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $fqdn,
|
|
'is_preview' => false,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
$allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments);
|
|
if ($magicEnvironments->count() > 0) {
|
|
// Generate Coolify environment variables
|
|
foreach ($magicEnvironments as $key => $value) {
|
|
$key = str($key);
|
|
$value = replaceVariables($value);
|
|
$command = parseCommandFromMagicEnvVariable($key);
|
|
if ($command->value() === 'FQDN') {
|
|
$fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
|
|
$originalFqdnFor = str($fqdnFor)->replace('_', '-');
|
|
if (str($fqdnFor)->contains('-')) {
|
|
$fqdnFor = str($fqdnFor)->replace('-', '_')->replace('.', '_');
|
|
}
|
|
// Generated FQDN & URL
|
|
$fqdn = generateFqdn(server: $server, random: "$originalFqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
|
|
$url = generateUrl(server: $server, random: "$originalFqdnFor-$uuid");
|
|
$resource->environment_variables()->firstOrCreate([
|
|
'key' => $key->value(),
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $fqdn,
|
|
'is_preview' => false,
|
|
]);
|
|
if ($resource->build_pack === 'dockercompose') {
|
|
// Check if a service with this name actually exists
|
|
$serviceExists = false;
|
|
foreach ($services as $serviceName => $service) {
|
|
$transformedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
|
if ($transformedServiceName === $fqdnFor) {
|
|
$serviceExists = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Only add domain if the service exists
|
|
if ($serviceExists) {
|
|
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
|
|
$domainExists = data_get($domains->get($fqdnFor), 'domain');
|
|
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
|
|
if (str($domainExists)->replace('http://', '')->replace('https://', '')->value() !== $envExists->value) {
|
|
$envExists->update([
|
|
'value' => $url,
|
|
]);
|
|
}
|
|
if (is_null($domainExists)) {
|
|
// Put URL in the domains array instead of FQDN
|
|
$domains->put((string) $fqdnFor, [
|
|
'domain' => $url,
|
|
]);
|
|
$resource->docker_compose_domains = $domains->toJson();
|
|
$resource->save();
|
|
}
|
|
}
|
|
}
|
|
} elseif ($command->value() === 'URL') {
|
|
$urlFor = $key->after('SERVICE_URL_')->lower()->value();
|
|
$originalUrlFor = str($urlFor)->replace('_', '-');
|
|
if (str($urlFor)->contains('-')) {
|
|
$urlFor = str($urlFor)->replace('-', '_')->replace('.', '_');
|
|
}
|
|
$url = generateUrl(server: $server, random: "$originalUrlFor-$uuid");
|
|
$resource->environment_variables()->firstOrCreate([
|
|
'key' => $key->value(),
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $url,
|
|
'is_preview' => false,
|
|
]);
|
|
if ($resource->build_pack === 'dockercompose') {
|
|
// Check if a service with this name actually exists
|
|
$serviceExists = false;
|
|
foreach ($services as $serviceName => $service) {
|
|
$transformedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
|
if ($transformedServiceName === $urlFor) {
|
|
$serviceExists = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Only add domain if the service exists
|
|
if ($serviceExists) {
|
|
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
|
|
$domainExists = data_get($domains->get($urlFor), 'domain');
|
|
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
|
|
if ($domainExists !== $envExists->value) {
|
|
$envExists->update([
|
|
'value' => $url,
|
|
]);
|
|
}
|
|
if (is_null($domainExists)) {
|
|
$domains->put((string) $urlFor, [
|
|
'domain' => $url,
|
|
]);
|
|
$resource->docker_compose_domains = $domains->toJson();
|
|
$resource->save();
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
$value = generateEnvValue($command, $resource);
|
|
$resource->environment_variables()->firstOrCreate([
|
|
'key' => $key->value(),
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $value,
|
|
'is_preview' => false,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// generate SERVICE_NAME variables for docker compose services
|
|
$serviceNameEnvironments = collect([]);
|
|
if ($resource->build_pack === 'dockercompose') {
|
|
$serviceNameEnvironments = generateDockerComposeServiceName($services, $pullRequestId);
|
|
}
|
|
|
|
// Parse the rest of the services
|
|
foreach ($services as $serviceName => $service) {
|
|
$image = data_get_str($service, 'image');
|
|
$restart = data_get_str($service, 'restart', RESTART_MODE);
|
|
$logging = data_get($service, 'logging');
|
|
|
|
if ($server->isLogDrainEnabled()) {
|
|
if ($resource->isLogDrainEnabled()) {
|
|
$logging = generate_fluentd_configuration();
|
|
}
|
|
}
|
|
$volumes = collect(data_get($service, 'volumes', []));
|
|
$networks = collect(data_get($service, 'networks', []));
|
|
$use_network_mode = data_get($service, 'network_mode') !== null;
|
|
$depends_on = collect(data_get($service, 'depends_on', []));
|
|
$labels = collect(data_get($service, 'labels', []));
|
|
if ($labels->count() > 0) {
|
|
if (isAssociativeArray($labels)) {
|
|
$newLabels = collect([]);
|
|
$labels->each(function ($value, $key) use ($newLabels) {
|
|
$newLabels->push("$key=$value");
|
|
});
|
|
$labels = $newLabels;
|
|
}
|
|
}
|
|
$environment = collect(data_get($service, 'environment', []));
|
|
$ports = collect(data_get($service, 'ports', []));
|
|
$buildArgs = collect(data_get($service, 'build.args', []));
|
|
$environment = $environment->merge($buildArgs);
|
|
|
|
$environment = convertToKeyValueCollection($environment);
|
|
$coolifyEnvironments = collect([]);
|
|
|
|
$isDatabase = isDatabaseImage($image, $service);
|
|
$volumesParsed = collect([]);
|
|
|
|
$baseName = generateApplicationContainerName(
|
|
application: $resource,
|
|
pull_request_id: $pullRequestId
|
|
);
|
|
$containerName = "$serviceName-$baseName";
|
|
$predefinedPort = null;
|
|
|
|
$originalResource = $resource;
|
|
|
|
if ($volumes->count() > 0) {
|
|
foreach ($volumes as $index => $volume) {
|
|
$type = null;
|
|
$source = null;
|
|
$target = null;
|
|
$content = null;
|
|
$isDirectory = false;
|
|
if (is_string($volume)) {
|
|
$parsed = parseDockerVolumeString($volume);
|
|
$source = $parsed['source'];
|
|
$target = $parsed['target'];
|
|
// Mode is available in $parsed['mode'] if needed
|
|
$foundConfig = $fileStorages->whereMountPath($target)->first();
|
|
if (sourceIsLocal($source)) {
|
|
$type = str('bind');
|
|
if ($foundConfig) {
|
|
$contentNotNull_temp = data_get($foundConfig, 'content');
|
|
if ($contentNotNull_temp) {
|
|
$content = $contentNotNull_temp;
|
|
}
|
|
$isDirectory = data_get($foundConfig, 'is_directory');
|
|
} else {
|
|
// By default, we cannot determine if the bind is a directory or not, so we set it to directory
|
|
$isDirectory = true;
|
|
}
|
|
} else {
|
|
$type = str('volume');
|
|
}
|
|
} elseif (is_array($volume)) {
|
|
$type = data_get_str($volume, 'type');
|
|
$source = data_get_str($volume, 'source');
|
|
$target = data_get_str($volume, 'target');
|
|
$content = data_get($volume, 'content');
|
|
$isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
|
|
|
|
// Validate source and target for command injection (array/long syntax)
|
|
if ($source !== null && ! empty($source->value())) {
|
|
$sourceValue = $source->value();
|
|
// Allow simple environment variable references
|
|
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
|
|
if (! $isSimpleEnvVar) {
|
|
try {
|
|
validateShellSafePath($sourceValue, 'volume source');
|
|
} catch (\Exception $e) {
|
|
throw new \Exception(
|
|
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
|
|
' Please use safe path names without shell metacharacters.'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if ($target !== null && ! empty($target->value())) {
|
|
try {
|
|
validateShellSafePath($target->value(), 'volume target');
|
|
} catch (\Exception $e) {
|
|
throw new \Exception(
|
|
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
|
|
' Please use safe path names without shell metacharacters.'
|
|
);
|
|
}
|
|
}
|
|
|
|
$foundConfig = $fileStorages->whereMountPath($target)->first();
|
|
if ($foundConfig) {
|
|
$contentNotNull_temp = data_get($foundConfig, 'content');
|
|
if ($contentNotNull_temp) {
|
|
$content = $contentNotNull_temp;
|
|
}
|
|
$isDirectory = data_get($foundConfig, 'is_directory');
|
|
} else {
|
|
// if isDirectory is not set (or false) & content is also not set, we assume it is a directory
|
|
if ((is_null($isDirectory) || ! $isDirectory) && is_null($content)) {
|
|
$isDirectory = true;
|
|
}
|
|
}
|
|
}
|
|
if ($type->value() === 'bind') {
|
|
if ($source->value() === '/var/run/docker.sock') {
|
|
$volume = $source->value().':'.$target->value();
|
|
if (isset($parsed['mode']) && $parsed['mode']) {
|
|
$volume .= ':'.$parsed['mode']->value();
|
|
}
|
|
} elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') {
|
|
$volume = $source->value().':'.$target->value();
|
|
if (isset($parsed['mode']) && $parsed['mode']) {
|
|
$volume .= ':'.$parsed['mode']->value();
|
|
}
|
|
} else {
|
|
if ((int) $resource->compose_parsing_version >= 4) {
|
|
$mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
|
|
} else {
|
|
$mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
|
|
}
|
|
$source = replaceLocalSource($source, $mainDirectory);
|
|
if ($isPullRequest) {
|
|
$source = addPreviewDeploymentSuffix($source, $pull_request_id);
|
|
}
|
|
LocalFileVolume::updateOrCreate(
|
|
[
|
|
'mount_path' => $target,
|
|
'resource_id' => $originalResource->id,
|
|
'resource_type' => get_class($originalResource),
|
|
],
|
|
[
|
|
'fs_path' => $source,
|
|
'mount_path' => $target,
|
|
'content' => $content,
|
|
'is_directory' => $isDirectory,
|
|
'resource_id' => $originalResource->id,
|
|
'resource_type' => get_class($originalResource),
|
|
]
|
|
);
|
|
if (isDev()) {
|
|
if ((int) $resource->compose_parsing_version >= 4) {
|
|
$source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid);
|
|
} else {
|
|
$source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid);
|
|
}
|
|
}
|
|
$volume = "$source:$target";
|
|
if (isset($parsed['mode']) && $parsed['mode']) {
|
|
$volume .= ':'.$parsed['mode']->value();
|
|
}
|
|
}
|
|
} elseif ($type->value() === 'volume') {
|
|
if ($topLevel->get('volumes')->has($source->value())) {
|
|
$temp = $topLevel->get('volumes')->get($source->value());
|
|
if (data_get($temp, 'driver_opts.type') === 'cifs') {
|
|
continue;
|
|
}
|
|
if (data_get($temp, 'driver_opts.type') === 'nfs') {
|
|
continue;
|
|
}
|
|
}
|
|
$slugWithoutUuid = Str::slug($source, '-');
|
|
$name = "{$uuid}_{$slugWithoutUuid}";
|
|
|
|
if ($isPullRequest) {
|
|
$name = addPreviewDeploymentSuffix($name, $pull_request_id);
|
|
}
|
|
if (is_string($volume)) {
|
|
$parsed = parseDockerVolumeString($volume);
|
|
$source = $parsed['source'];
|
|
$target = $parsed['target'];
|
|
$source = $name;
|
|
$volume = "$source:$target";
|
|
if (isset($parsed['mode']) && $parsed['mode']) {
|
|
$volume .= ':'.$parsed['mode']->value();
|
|
}
|
|
} elseif (is_array($volume)) {
|
|
data_set($volume, 'source', $name);
|
|
}
|
|
$topLevel->get('volumes')->put($name, [
|
|
'name' => $name,
|
|
]);
|
|
LocalPersistentVolume::updateOrCreate(
|
|
[
|
|
'name' => $name,
|
|
'resource_id' => $originalResource->id,
|
|
'resource_type' => get_class($originalResource),
|
|
],
|
|
[
|
|
'name' => $name,
|
|
'mount_path' => $target,
|
|
'resource_id' => $originalResource->id,
|
|
'resource_type' => get_class($originalResource),
|
|
]
|
|
);
|
|
}
|
|
dispatch(new ServerFilesFromServerJob($originalResource));
|
|
$volumesParsed->put($index, $volume);
|
|
}
|
|
}
|
|
|
|
if ($depends_on?->count() > 0) {
|
|
if ($isPullRequest) {
|
|
$newDependsOn = collect([]);
|
|
$depends_on->each(function ($dependency, $condition) use ($pullRequestId, $newDependsOn) {
|
|
if (is_numeric($condition)) {
|
|
$dependency = addPreviewDeploymentSuffix($dependency, $pullRequestId);
|
|
|
|
$newDependsOn->put($condition, $dependency);
|
|
} else {
|
|
$condition = addPreviewDeploymentSuffix($condition, $pullRequestId);
|
|
$newDependsOn->put($condition, $dependency);
|
|
}
|
|
});
|
|
$depends_on = $newDependsOn;
|
|
}
|
|
}
|
|
if (! $use_network_mode) {
|
|
if ($topLevel->get('networks')?->count() > 0) {
|
|
foreach ($topLevel->get('networks') as $networkName => $network) {
|
|
if ($networkName === 'default') {
|
|
continue;
|
|
}
|
|
// ignore aliases
|
|
if ($network['aliases'] ?? false) {
|
|
continue;
|
|
}
|
|
$networkExists = $networks->contains(function ($value, $key) use ($networkName) {
|
|
return $value == $networkName || $key == $networkName;
|
|
});
|
|
if (! $networkExists) {
|
|
$networks->put($networkName, null);
|
|
}
|
|
}
|
|
}
|
|
$baseNetworkExists = $networks->contains(function ($value, $_) use ($baseNetwork) {
|
|
return $value == $baseNetwork;
|
|
});
|
|
if (! $baseNetworkExists) {
|
|
foreach ($baseNetwork as $network) {
|
|
$topLevel->get('networks')->put($network, [
|
|
'name' => $network,
|
|
'external' => true,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect/create/update ports
|
|
$collectedPorts = collect([]);
|
|
if ($ports->count() > 0) {
|
|
foreach ($ports as $sport) {
|
|
if (is_string($sport) || is_numeric($sport)) {
|
|
$collectedPorts->push($sport);
|
|
}
|
|
if (is_array($sport)) {
|
|
$target = data_get($sport, 'target');
|
|
$published = data_get($sport, 'published');
|
|
$protocol = data_get($sport, 'protocol');
|
|
$collectedPorts->push("$target:$published/$protocol");
|
|
}
|
|
}
|
|
}
|
|
|
|
$networks_temp = collect();
|
|
|
|
if (! $use_network_mode) {
|
|
foreach ($networks as $key => $network) {
|
|
if (gettype($network) === 'string') {
|
|
// networks:
|
|
// - appwrite
|
|
$networks_temp->put($network, null);
|
|
} elseif (gettype($network) === 'array') {
|
|
// networks:
|
|
// default:
|
|
// ipv4_address: 192.168.203.254
|
|
$networks_temp->put($key, $network);
|
|
}
|
|
}
|
|
foreach ($baseNetwork as $key => $network) {
|
|
$networks_temp->put($network, null);
|
|
}
|
|
|
|
if (data_get($resource, 'settings.connect_to_docker_network')) {
|
|
$network = $resource->destination->network;
|
|
$networks_temp->put($network, null);
|
|
$topLevel->get('networks')->put($network, [
|
|
'name' => $network,
|
|
'external' => true,
|
|
]);
|
|
}
|
|
}
|
|
|
|
$normalEnvironments = $environment->diffKeys($allMagicEnvironments);
|
|
$normalEnvironments = $normalEnvironments->filter(function ($value, $key) {
|
|
return ! str($value)->startsWith('SERVICE_');
|
|
});
|
|
foreach ($normalEnvironments as $key => $value) {
|
|
$key = str($key);
|
|
$value = str($value);
|
|
$originalValue = $value;
|
|
$parsedValue = replaceVariables($value);
|
|
if ($value->startsWith('$SERVICE_')) {
|
|
$resource->environment_variables()->firstOrCreate([
|
|
'key' => $key,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $value,
|
|
'is_preview' => false,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
if (! $value->startsWith('$')) {
|
|
continue;
|
|
}
|
|
if ($key->value() === $parsedValue->value()) {
|
|
$value = null;
|
|
$resource->environment_variables()->firstOrCreate([
|
|
'key' => $key,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $value,
|
|
'is_preview' => false,
|
|
]);
|
|
} else {
|
|
if ($value->startsWith('$')) {
|
|
$isRequired = false;
|
|
if ($value->contains(':-')) {
|
|
$value = replaceVariables($value);
|
|
$key = $value->before(':');
|
|
$value = $value->after(':-');
|
|
} elseif ($value->contains('-')) {
|
|
$value = replaceVariables($value);
|
|
|
|
$key = $value->before('-');
|
|
$value = $value->after('-');
|
|
} elseif ($value->contains(':?')) {
|
|
$value = replaceVariables($value);
|
|
|
|
$key = $value->before(':');
|
|
$value = $value->after(':?');
|
|
$isRequired = true;
|
|
} elseif ($value->contains('?')) {
|
|
$value = replaceVariables($value);
|
|
|
|
$key = $value->before('?');
|
|
$value = $value->after('?');
|
|
$isRequired = true;
|
|
}
|
|
if ($originalValue->value() === $value->value()) {
|
|
// This means the variable does not have a default value, so it needs to be created in Coolify
|
|
$parsedKeyValue = replaceVariables($value);
|
|
$resource->environment_variables()->firstOrCreate([
|
|
'key' => $parsedKeyValue,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'is_preview' => false,
|
|
'is_required' => $isRequired,
|
|
]);
|
|
// Add the variable to the environment so it will be shown in the deployable compose file
|
|
$environment[$parsedKeyValue->value()] = $value;
|
|
|
|
continue;
|
|
}
|
|
$resource->environment_variables()->firstOrCreate([
|
|
'key' => $key,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $value,
|
|
'is_preview' => false,
|
|
'is_required' => $isRequired,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
$branch = $originalResource->git_branch;
|
|
if ($pullRequestId !== 0) {
|
|
$branch = "pull/{$pullRequestId}/head";
|
|
}
|
|
if ($originalResource->environment_variables->where('key', 'COOLIFY_BRANCH')->isEmpty()) {
|
|
$coolifyEnvironments->put('COOLIFY_BRANCH', "\"{$branch}\"");
|
|
}
|
|
|
|
// Add COOLIFY_RESOURCE_UUID to environment
|
|
if ($resource->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
|
|
$coolifyEnvironments->put('COOLIFY_RESOURCE_UUID', "{$resource->uuid}");
|
|
}
|
|
|
|
// Add COOLIFY_CONTAINER_NAME to environment
|
|
if ($resource->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
|
|
$coolifyEnvironments->put('COOLIFY_CONTAINER_NAME', "{$containerName}");
|
|
}
|
|
|
|
if ($isPullRequest) {
|
|
$preview = $resource->previews()->find($preview_id);
|
|
$domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))) ?? collect([]);
|
|
} else {
|
|
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
|
|
}
|
|
|
|
// Only process domains for dockercompose applications to prevent SERVICE variable recreation
|
|
if ($resource->build_pack !== 'dockercompose') {
|
|
$domains = collect([]);
|
|
}
|
|
$changedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
|
$fqdns = data_get($domains, "$changedServiceName.domain");
|
|
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
|
|
if ($resource->build_pack === 'dockercompose') {
|
|
foreach ($domains as $forServiceName => $domain) {
|
|
$parsedDomain = data_get($domain, 'domain');
|
|
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
|
|
|
|
if (filled($parsedDomain)) {
|
|
$parsedDomain = str($parsedDomain)->explode(',')->first();
|
|
$coolifyUrl = Url::fromString($parsedDomain);
|
|
$coolifyScheme = $coolifyUrl->getScheme();
|
|
$coolifyFqdn = $coolifyUrl->getHost();
|
|
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
|
$coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyUrl->__toString());
|
|
$coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyFqdn);
|
|
$resource->environment_variables()->updateOrCreate([
|
|
'resourceable_type' => Application::class,
|
|
'resourceable_id' => $resource->id,
|
|
'key' => 'SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
|
|
], [
|
|
'value' => $coolifyUrl->__toString(),
|
|
'is_preview' => false,
|
|
]);
|
|
$resource->environment_variables()->updateOrCreate([
|
|
'resourceable_type' => Application::class,
|
|
'resourceable_id' => $resource->id,
|
|
'key' => 'SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
|
|
], [
|
|
'value' => $coolifyFqdn,
|
|
'is_preview' => false,
|
|
]);
|
|
} else {
|
|
$resource->environment_variables()->where('resourceable_type', Application::class)
|
|
->where('resourceable_id', $resource->id)
|
|
->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%")
|
|
->update([
|
|
'value' => null,
|
|
]);
|
|
$resource->environment_variables()->where('resourceable_type', Application::class)
|
|
->where('resourceable_id', $resource->id)
|
|
->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%")
|
|
->update([
|
|
'value' => null,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
// If the domain is set, we need to generate the FQDNs for the preview
|
|
if (filled($fqdns)) {
|
|
$fqdns = str($fqdns)->explode(',');
|
|
if ($isPullRequest) {
|
|
$preview = $resource->previews()->find($preview_id);
|
|
$docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains')));
|
|
if ($docker_compose_domains->count() > 0) {
|
|
$found_fqdn = data_get($docker_compose_domains, "$changedServiceName.domain");
|
|
if ($found_fqdn) {
|
|
$fqdns = collect($found_fqdn);
|
|
} else {
|
|
$fqdns = collect([]);
|
|
}
|
|
} else {
|
|
$fqdns = $fqdns->map(function ($fqdn) use ($pullRequestId, $resource) {
|
|
$preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pullRequestId);
|
|
$url = Url::fromString($fqdn);
|
|
$template = $resource->preview_url_template;
|
|
$host = $url->getHost();
|
|
$schema = $url->getScheme();
|
|
$random = new Cuid2;
|
|
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
|
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
|
$preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
|
|
$preview_fqdn = "$schema://$preview_fqdn";
|
|
$preview->fqdn = $preview_fqdn;
|
|
$preview->save();
|
|
|
|
return $preview_fqdn;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
$defaultLabels = defaultLabels(
|
|
id: $resource->id,
|
|
name: $containerName,
|
|
projectName: $resource->project()->name,
|
|
resourceName: $resource->name,
|
|
pull_request_id: $pullRequestId,
|
|
type: 'application',
|
|
environment: $resource->environment->name,
|
|
);
|
|
|
|
$isDatabase = isDatabaseImage($image, $service);
|
|
// Add COOLIFY_FQDN & COOLIFY_URL to environment
|
|
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
|
|
$fqdnsWithoutPort = $fqdns->map(function ($fqdn) {
|
|
return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://'));
|
|
});
|
|
$coolifyEnvironments->put('COOLIFY_URL', $fqdnsWithoutPort->implode(','));
|
|
|
|
$urls = $fqdns->map(function ($fqdn) {
|
|
return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':');
|
|
});
|
|
$coolifyEnvironments->put('COOLIFY_FQDN', $urls->implode(','));
|
|
}
|
|
add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
|
|
if ($environment->count() > 0) {
|
|
$environment = $environment->filter(function ($value, $key) {
|
|
return ! str($key)->startsWith('SERVICE_FQDN_');
|
|
})->map(function ($value, $key) use ($resource) {
|
|
// if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used
|
|
if (str($value)->isEmpty()) {
|
|
if ($resource->environment_variables()->where('key', $key)->exists()) {
|
|
$value = $resource->environment_variables()->where('key', $key)->first()->value;
|
|
} else {
|
|
$value = null;
|
|
}
|
|
}
|
|
|
|
return $value;
|
|
});
|
|
}
|
|
$serviceLabels = $labels->merge($defaultLabels);
|
|
if ($serviceLabels->count() > 0) {
|
|
$isContainerLabelEscapeEnabled = data_get($resource, 'settings.is_container_label_escape_enabled');
|
|
if ($isContainerLabelEscapeEnabled) {
|
|
$serviceLabels = $serviceLabels->map(function ($value, $key) {
|
|
return escapeDollarSign($value);
|
|
});
|
|
}
|
|
}
|
|
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
|
|
$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;
|
|
$uuid = $resource->uuid;
|
|
$network = data_get($resource, 'destination.network');
|
|
if ($isPullRequest) {
|
|
$uuid = "{$resource->uuid}-{$pullRequestId}";
|
|
}
|
|
if ($isPullRequest) {
|
|
$network = "{$resource->destination->network}-{$pullRequestId}";
|
|
}
|
|
if ($shouldGenerateLabelsExactly) {
|
|
switch ($server->proxyType()) {
|
|
case ProxyTypes::TRAEFIK->value:
|
|
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
|
|
uuid: $uuid,
|
|
domains: $fqdns,
|
|
is_force_https_enabled: true,
|
|
serviceLabels: $serviceLabels,
|
|
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
|
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
|
service_name: $serviceName,
|
|
image: $image
|
|
));
|
|
break;
|
|
case ProxyTypes::CADDY->value:
|
|
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
|
network: $network,
|
|
uuid: $uuid,
|
|
domains: $fqdns,
|
|
is_force_https_enabled: true,
|
|
serviceLabels: $serviceLabels,
|
|
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
|
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
|
service_name: $serviceName,
|
|
image: $image,
|
|
predefinedPort: $predefinedPort
|
|
));
|
|
break;
|
|
}
|
|
} else {
|
|
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
|
|
uuid: $uuid,
|
|
domains: $fqdns,
|
|
is_force_https_enabled: true,
|
|
serviceLabels: $serviceLabels,
|
|
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
|
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
|
service_name: $serviceName,
|
|
image: $image
|
|
));
|
|
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
|
network: $network,
|
|
uuid: $uuid,
|
|
domains: $fqdns,
|
|
is_force_https_enabled: true,
|
|
serviceLabels: $serviceLabels,
|
|
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
|
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
|
service_name: $serviceName,
|
|
image: $image,
|
|
predefinedPort: $predefinedPort
|
|
));
|
|
}
|
|
}
|
|
data_forget($service, 'volumes.*.content');
|
|
data_forget($service, 'volumes.*.isDirectory');
|
|
data_forget($service, 'volumes.*.is_directory');
|
|
data_forget($service, 'exclude_from_hc');
|
|
|
|
$volumesParsed = $volumesParsed->map(function ($volume) {
|
|
data_forget($volume, 'content');
|
|
data_forget($volume, 'is_directory');
|
|
data_forget($volume, 'isDirectory');
|
|
|
|
return $volume;
|
|
});
|
|
|
|
$payload = collect($service)->merge([
|
|
'container_name' => $containerName,
|
|
'restart' => $restart->value(),
|
|
'labels' => $serviceLabels,
|
|
]);
|
|
if (! $use_network_mode) {
|
|
$payload['networks'] = $networks_temp;
|
|
}
|
|
if ($ports->count() > 0) {
|
|
$payload['ports'] = $ports;
|
|
}
|
|
if ($volumesParsed->count() > 0) {
|
|
$payload['volumes'] = $volumesParsed;
|
|
}
|
|
if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) {
|
|
$payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments);
|
|
}
|
|
if ($logging) {
|
|
$payload['logging'] = $logging;
|
|
}
|
|
if ($depends_on->count() > 0) {
|
|
$payload['depends_on'] = $depends_on;
|
|
}
|
|
if ($isPullRequest) {
|
|
$serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId);
|
|
}
|
|
|
|
$parsedServices->put($serviceName, $payload);
|
|
}
|
|
$topLevel->put('services', $parsedServices);
|
|
|
|
$customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets'];
|
|
|
|
$topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) {
|
|
return array_search($key, $customOrder);
|
|
});
|
|
|
|
$resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2);
|
|
data_forget($resource, 'environment_variables');
|
|
data_forget($resource, 'environment_variables_preview');
|
|
$resource->save();
|
|
|
|
return $topLevel;
|
|
}
|
|
|
|
function serviceParser(Service $resource): Collection
|
|
{
|
|
$uuid = data_get($resource, 'uuid');
|
|
$compose = data_get($resource, 'docker_compose_raw');
|
|
if (! $compose) {
|
|
return collect([]);
|
|
}
|
|
|
|
$server = data_get($resource, 'server');
|
|
$allServices = get_service_templates();
|
|
|
|
try {
|
|
$yaml = Yaml::parse($compose);
|
|
} catch (\Exception) {
|
|
return collect([]);
|
|
}
|
|
$services = data_get($yaml, 'services', collect([]));
|
|
$topLevel = collect([
|
|
'volumes' => collect(data_get($yaml, 'volumes', [])),
|
|
'networks' => collect(data_get($yaml, 'networks', [])),
|
|
'configs' => collect(data_get($yaml, 'configs', [])),
|
|
'secrets' => collect(data_get($yaml, 'secrets', [])),
|
|
]);
|
|
// If there are predefined volumes, make sure they are not null
|
|
if ($topLevel->get('volumes')->count() > 0) {
|
|
$temp = collect([]);
|
|
foreach ($topLevel['volumes'] as $volumeName => $volume) {
|
|
if (is_null($volume)) {
|
|
continue;
|
|
}
|
|
$temp->put($volumeName, $volume);
|
|
}
|
|
$topLevel['volumes'] = $temp;
|
|
}
|
|
// Get the base docker network
|
|
$baseNetwork = collect([$uuid]);
|
|
|
|
$parsedServices = collect([]);
|
|
|
|
// Generate SERVICE_NAME variables for docker compose services
|
|
$serviceNameEnvironments = generateDockerComposeServiceName($services);
|
|
|
|
$allMagicEnvironments = collect([]);
|
|
// Presave services
|
|
foreach ($services as $serviceName => $service) {
|
|
// Validate service name for command injection
|
|
try {
|
|
validateShellSafePath($serviceName, 'service name');
|
|
} catch (\Exception $e) {
|
|
throw new \Exception(
|
|
'Invalid Docker Compose service name: '.$e->getMessage().
|
|
' Service names must not contain shell metacharacters.'
|
|
);
|
|
}
|
|
|
|
$image = data_get_str($service, 'image');
|
|
$isDatabase = isDatabaseImage($image, $service);
|
|
if ($isDatabase) {
|
|
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
|
|
if ($applicationFound) {
|
|
$savedService = $applicationFound;
|
|
} else {
|
|
$savedService = ServiceDatabase::firstOrCreate([
|
|
'name' => $serviceName,
|
|
'service_id' => $resource->id,
|
|
]);
|
|
}
|
|
} else {
|
|
$savedService = ServiceApplication::firstOrCreate([
|
|
'name' => $serviceName,
|
|
'service_id' => $resource->id,
|
|
]);
|
|
}
|
|
// Update image if it changed
|
|
if ($savedService->image !== $image) {
|
|
$savedService->image = $image;
|
|
$savedService->save();
|
|
}
|
|
}
|
|
foreach ($services as $serviceName => $service) {
|
|
$predefinedPort = null;
|
|
$magicEnvironments = collect([]);
|
|
$image = data_get_str($service, 'image');
|
|
$environment = collect(data_get($service, 'environment', []));
|
|
$buildArgs = collect(data_get($service, 'build.args', []));
|
|
$environment = $environment->merge($buildArgs);
|
|
$isDatabase = isDatabaseImage($image, $service);
|
|
|
|
$containerName = "$serviceName-{$resource->uuid}";
|
|
|
|
if ($serviceName === 'registry') {
|
|
$tempServiceName = 'docker-registry';
|
|
} else {
|
|
$tempServiceName = $serviceName;
|
|
}
|
|
if (str(data_get($service, 'image'))->contains('glitchtip')) {
|
|
$tempServiceName = 'glitchtip';
|
|
}
|
|
if ($serviceName === 'supabase-kong') {
|
|
$tempServiceName = 'supabase';
|
|
}
|
|
$serviceDefinition = data_get($allServices, $tempServiceName);
|
|
$predefinedPort = data_get($serviceDefinition, 'port');
|
|
if ($serviceName === 'plausible') {
|
|
$predefinedPort = '8000';
|
|
}
|
|
if ($isDatabase) {
|
|
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
|
|
if ($applicationFound) {
|
|
$savedService = $applicationFound;
|
|
} else {
|
|
$savedService = ServiceDatabase::firstOrCreate([
|
|
'name' => $serviceName,
|
|
'service_id' => $resource->id,
|
|
]);
|
|
}
|
|
} else {
|
|
$savedService = ServiceApplication::firstOrCreate([
|
|
'name' => $serviceName,
|
|
'service_id' => $resource->id,
|
|
], [
|
|
'is_gzip_enabled' => true,
|
|
]);
|
|
}
|
|
// Check if image changed
|
|
if ($savedService->image !== $image) {
|
|
$savedService->image = $image;
|
|
$savedService->save();
|
|
}
|
|
// Pocketbase does not need gzip for SSE.
|
|
if (str($savedService->image)->contains('pocketbase') && $savedService->is_gzip_enabled) {
|
|
$savedService->is_gzip_enabled = false;
|
|
$savedService->save();
|
|
}
|
|
|
|
$environment = collect(data_get($service, 'environment', []));
|
|
$buildArgs = collect(data_get($service, 'build.args', []));
|
|
$environment = $environment->merge($buildArgs);
|
|
|
|
// convert environment variables to one format
|
|
$environment = convertToKeyValueCollection($environment);
|
|
|
|
// Add Coolify defined environments
|
|
$allEnvironments = $resource->environment_variables()->get(['key', 'value']);
|
|
|
|
$allEnvironments = $allEnvironments->mapWithKeys(function ($item) {
|
|
return [$item['key'] => $item['value']];
|
|
});
|
|
// filter and add magic environments
|
|
foreach ($environment as $key => $value) {
|
|
// Get all SERVICE_ variables from keys and values
|
|
$key = str($key);
|
|
$value = str($value);
|
|
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
|
|
preg_match_all($regex, $value, $valueMatches);
|
|
if (count($valueMatches[1]) > 0) {
|
|
foreach ($valueMatches[1] as $match) {
|
|
$match = replaceVariables($match);
|
|
if ($match->startsWith('SERVICE_')) {
|
|
if ($magicEnvironments->has($match->value())) {
|
|
continue;
|
|
}
|
|
$magicEnvironments->put($match->value(), '');
|
|
}
|
|
}
|
|
}
|
|
// Get magic environments where we need to preset the FQDN / URL
|
|
if ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_')) {
|
|
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
|
|
if (substr_count(str($key)->value(), '_') === 3) {
|
|
if ($key->startsWith('SERVICE_FQDN_')) {
|
|
$urlFor = null;
|
|
$fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
|
|
}
|
|
if ($key->startsWith('SERVICE_URL_')) {
|
|
$fqdnFor = null;
|
|
$urlFor = $key->after('SERVICE_URL_')->beforeLast('_')->lower()->value();
|
|
}
|
|
$port = $key->afterLast('_')->value();
|
|
} else {
|
|
if ($key->startsWith('SERVICE_FQDN_')) {
|
|
$urlFor = null;
|
|
$fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
|
|
}
|
|
if ($key->startsWith('SERVICE_URL_')) {
|
|
$fqdnFor = null;
|
|
$urlFor = $key->after('SERVICE_URL_')->lower()->value();
|
|
}
|
|
$port = null;
|
|
}
|
|
if (blank($savedService->fqdn)) {
|
|
if ($fqdnFor) {
|
|
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
|
|
} else {
|
|
$fqdn = generateFqdn(server: $server, random: "{$savedService->name}-$uuid", parserVersion: $resource->compose_parsing_version);
|
|
}
|
|
if ($urlFor) {
|
|
$url = generateUrl($server, "$urlFor-$uuid");
|
|
} else {
|
|
$url = generateUrl($server, "{$savedService->name}-$uuid");
|
|
}
|
|
} else {
|
|
$fqdn = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
|
|
$url = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
|
|
}
|
|
|
|
if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
|
|
$path = $value->value();
|
|
if ($path !== '/') {
|
|
$fqdn = "$fqdn$path";
|
|
$url = "$url$path";
|
|
}
|
|
}
|
|
$fqdnWithPort = $fqdn;
|
|
$urlWithPort = $url;
|
|
if ($fqdn && $port) {
|
|
$fqdnWithPort = "$fqdn:$port";
|
|
}
|
|
if ($url && $port) {
|
|
$urlWithPort = "$url:$port";
|
|
}
|
|
if (is_null($savedService->fqdn)) {
|
|
if ((int) $resource->compose_parsing_version >= 5 && version_compare(config('constants.coolify.version'), '4.0.0-beta.420.7', '>=')) {
|
|
if ($fqdnFor) {
|
|
$savedService->fqdn = $fqdnWithPort;
|
|
}
|
|
if ($urlFor) {
|
|
$savedService->fqdn = $urlWithPort;
|
|
}
|
|
} else {
|
|
$savedService->fqdn = $fqdnWithPort;
|
|
}
|
|
$savedService->save();
|
|
}
|
|
if (substr_count(str($key)->value(), '_') === 2) {
|
|
$resource->environment_variables()->updateOrCreate([
|
|
'key' => $key->value(),
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $fqdn,
|
|
'is_preview' => false,
|
|
]);
|
|
$resource->environment_variables()->updateOrCreate([
|
|
'key' => $key->value(),
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $url,
|
|
'is_preview' => false,
|
|
]);
|
|
}
|
|
if (substr_count(str($key)->value(), '_') === 3) {
|
|
$newKey = str($key)->beforeLast('_');
|
|
$resource->environment_variables()->updateOrCreate([
|
|
'key' => $newKey->value(),
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $fqdn,
|
|
'is_preview' => false,
|
|
]);
|
|
$resource->environment_variables()->updateOrCreate([
|
|
'key' => $newKey->value(),
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $url,
|
|
'is_preview' => false,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
$allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments);
|
|
if ($magicEnvironments->count() > 0) {
|
|
foreach ($magicEnvironments as $key => $value) {
|
|
$key = str($key);
|
|
$value = replaceVariables($value);
|
|
$command = parseCommandFromMagicEnvVariable($key);
|
|
if ($command->value() === 'FQDN') {
|
|
$fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
|
|
$fqdn = generateFqdn(server: $server, random: str($fqdnFor)->replace('_', '-')->value()."-$uuid", parserVersion: $resource->compose_parsing_version);
|
|
$url = generateUrl(server: $server, random: str($fqdnFor)->replace('_', '-')->value()."-$uuid");
|
|
|
|
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
|
|
$serviceExists = ServiceApplication::where('name', str($fqdnFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
|
|
if (! $envExists && (data_get($serviceExists, 'name') === str($fqdnFor)->replace('_', '-')->value())) {
|
|
// Save URL otherwise it won't work.
|
|
$serviceExists->fqdn = $url;
|
|
$serviceExists->save();
|
|
}
|
|
$resource->environment_variables()->firstOrCreate([
|
|
'key' => $key->value(),
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $fqdn,
|
|
'is_preview' => false,
|
|
]);
|
|
|
|
} elseif ($command->value() === 'URL') {
|
|
$urlFor = $key->after('SERVICE_URL_')->lower()->value();
|
|
$url = generateUrl(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid");
|
|
|
|
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
|
|
$serviceExists = ServiceApplication::where('name', str($urlFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
|
|
if (! $envExists && (data_get($serviceExists, 'name') === str($urlFor)->replace('_', '-')->value())) {
|
|
$serviceExists->fqdn = $url;
|
|
$serviceExists->save();
|
|
}
|
|
$resource->environment_variables()->firstOrCreate([
|
|
'key' => $key->value(),
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $url,
|
|
'is_preview' => false,
|
|
]);
|
|
|
|
} else {
|
|
$value = generateEnvValue($command, $resource);
|
|
$resource->environment_variables()->firstOrCreate([
|
|
'key' => $key->value(),
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $value,
|
|
'is_preview' => false,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$serviceAppsLogDrainEnabledMap = $resource->applications()->get()->keyBy('name')->map(function ($app) {
|
|
return $app->isLogDrainEnabled();
|
|
});
|
|
|
|
// Parse the rest of the services
|
|
foreach ($services as $serviceName => $service) {
|
|
$image = data_get_str($service, 'image');
|
|
$restart = data_get_str($service, 'restart', RESTART_MODE);
|
|
$logging = data_get($service, 'logging');
|
|
|
|
if ($server->isLogDrainEnabled()) {
|
|
if ($serviceAppsLogDrainEnabledMap->get($serviceName)) {
|
|
$logging = generate_fluentd_configuration();
|
|
}
|
|
}
|
|
$volumes = collect(data_get($service, 'volumes', []));
|
|
$networks = collect(data_get($service, 'networks', []));
|
|
$use_network_mode = data_get($service, 'network_mode') !== null;
|
|
$depends_on = collect(data_get($service, 'depends_on', []));
|
|
$labels = collect(data_get($service, 'labels', []));
|
|
if ($labels->count() > 0) {
|
|
if (isAssociativeArray($labels)) {
|
|
$newLabels = collect([]);
|
|
$labels->each(function ($value, $key) use ($newLabels) {
|
|
$newLabels->push("$key=$value");
|
|
});
|
|
$labels = $newLabels;
|
|
}
|
|
}
|
|
$environment = collect(data_get($service, 'environment', []));
|
|
$ports = collect(data_get($service, 'ports', []));
|
|
$buildArgs = collect(data_get($service, 'build.args', []));
|
|
$environment = $environment->merge($buildArgs);
|
|
|
|
$environment = convertToKeyValueCollection($environment);
|
|
$coolifyEnvironments = collect([]);
|
|
|
|
$isDatabase = isDatabaseImage($image, $service);
|
|
$volumesParsed = collect([]);
|
|
|
|
$containerName = "$serviceName-{$resource->uuid}";
|
|
|
|
if ($serviceName === 'registry') {
|
|
$tempServiceName = 'docker-registry';
|
|
} else {
|
|
$tempServiceName = $serviceName;
|
|
}
|
|
if (str(data_get($service, 'image'))->contains('glitchtip')) {
|
|
$tempServiceName = 'glitchtip';
|
|
}
|
|
if ($serviceName === 'supabase-kong') {
|
|
$tempServiceName = 'supabase';
|
|
}
|
|
$serviceDefinition = data_get($allServices, $tempServiceName);
|
|
$predefinedPort = data_get($serviceDefinition, 'port');
|
|
if ($serviceName === 'plausible') {
|
|
$predefinedPort = '8000';
|
|
}
|
|
|
|
if ($isDatabase) {
|
|
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
|
|
if ($applicationFound) {
|
|
$savedService = $applicationFound;
|
|
} else {
|
|
$savedService = ServiceDatabase::firstOrCreate([
|
|
'name' => $serviceName,
|
|
'service_id' => $resource->id,
|
|
]);
|
|
}
|
|
} else {
|
|
$savedService = ServiceApplication::firstOrCreate([
|
|
'name' => $serviceName,
|
|
'service_id' => $resource->id,
|
|
]);
|
|
}
|
|
$fileStorages = $savedService->fileStorages();
|
|
if ($savedService->image !== $image) {
|
|
$savedService->image = $image;
|
|
$savedService->save();
|
|
}
|
|
|
|
$originalResource = $savedService;
|
|
|
|
if ($volumes->count() > 0) {
|
|
foreach ($volumes as $index => $volume) {
|
|
$type = null;
|
|
$source = null;
|
|
$target = null;
|
|
$content = null;
|
|
$isDirectory = false;
|
|
if (is_string($volume)) {
|
|
$parsed = parseDockerVolumeString($volume);
|
|
$source = $parsed['source'];
|
|
$target = $parsed['target'];
|
|
// Mode is available in $parsed['mode'] if needed
|
|
$foundConfig = $fileStorages->whereMountPath($target)->first();
|
|
if (sourceIsLocal($source)) {
|
|
$type = str('bind');
|
|
if ($foundConfig) {
|
|
$contentNotNull_temp = data_get($foundConfig, 'content');
|
|
if ($contentNotNull_temp) {
|
|
$content = $contentNotNull_temp;
|
|
}
|
|
$isDirectory = data_get($foundConfig, 'is_directory');
|
|
} else {
|
|
// By default, we cannot determine if the bind is a directory or not, so we set it to directory
|
|
$isDirectory = true;
|
|
}
|
|
} else {
|
|
$type = str('volume');
|
|
}
|
|
} elseif (is_array($volume)) {
|
|
$type = data_get_str($volume, 'type');
|
|
$source = data_get_str($volume, 'source');
|
|
$target = data_get_str($volume, 'target');
|
|
$content = data_get($volume, 'content');
|
|
$isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
|
|
|
|
// Validate source and target for command injection (array/long syntax)
|
|
if ($source !== null && ! empty($source->value())) {
|
|
$sourceValue = $source->value();
|
|
// Allow simple environment variable references
|
|
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
|
|
if (! $isSimpleEnvVar) {
|
|
try {
|
|
validateShellSafePath($sourceValue, 'volume source');
|
|
} catch (\Exception $e) {
|
|
throw new \Exception(
|
|
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
|
|
' Please use safe path names without shell metacharacters.'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if ($target !== null && ! empty($target->value())) {
|
|
try {
|
|
validateShellSafePath($target->value(), 'volume target');
|
|
} catch (\Exception $e) {
|
|
throw new \Exception(
|
|
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
|
|
' Please use safe path names without shell metacharacters.'
|
|
);
|
|
}
|
|
}
|
|
|
|
$foundConfig = $fileStorages->whereMountPath($target)->first();
|
|
if ($foundConfig) {
|
|
$contentNotNull_temp = data_get($foundConfig, 'content');
|
|
if ($contentNotNull_temp) {
|
|
$content = $contentNotNull_temp;
|
|
}
|
|
$isDirectory = data_get($foundConfig, 'is_directory');
|
|
} else {
|
|
// if isDirectory is not set (or false) & content is also not set, we assume it is a directory
|
|
if ((is_null($isDirectory) || ! $isDirectory) && is_null($content)) {
|
|
$isDirectory = true;
|
|
}
|
|
}
|
|
}
|
|
if ($type->value() === 'bind') {
|
|
if ($source->value() === '/var/run/docker.sock') {
|
|
$volume = $source->value().':'.$target->value();
|
|
if (isset($parsed['mode']) && $parsed['mode']) {
|
|
$volume .= ':'.$parsed['mode']->value();
|
|
}
|
|
} elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') {
|
|
$volume = $source->value().':'.$target->value();
|
|
if (isset($parsed['mode']) && $parsed['mode']) {
|
|
$volume .= ':'.$parsed['mode']->value();
|
|
}
|
|
} else {
|
|
if ((int) $resource->compose_parsing_version >= 4) {
|
|
$mainDirectory = str(base_configuration_dir().'/services/'.$uuid);
|
|
} else {
|
|
$mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
|
|
}
|
|
$source = replaceLocalSource($source, $mainDirectory);
|
|
LocalFileVolume::updateOrCreate(
|
|
[
|
|
'mount_path' => $target,
|
|
'resource_id' => $originalResource->id,
|
|
'resource_type' => get_class($originalResource),
|
|
],
|
|
[
|
|
'fs_path' => $source,
|
|
'mount_path' => $target,
|
|
'content' => $content,
|
|
'is_directory' => $isDirectory,
|
|
'resource_id' => $originalResource->id,
|
|
'resource_type' => get_class($originalResource),
|
|
]
|
|
);
|
|
if (isDev()) {
|
|
if ((int) $resource->compose_parsing_version >= 4) {
|
|
$source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/services/'.$uuid);
|
|
} else {
|
|
$source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid);
|
|
}
|
|
}
|
|
$volume = "$source:$target";
|
|
if (isset($parsed['mode']) && $parsed['mode']) {
|
|
$volume .= ':'.$parsed['mode']->value();
|
|
}
|
|
}
|
|
} elseif ($type->value() === 'volume') {
|
|
if ($topLevel->get('volumes')->has($source->value())) {
|
|
$temp = $topLevel->get('volumes')->get($source->value());
|
|
if (data_get($temp, 'driver_opts.type') === 'cifs') {
|
|
continue;
|
|
}
|
|
if (data_get($temp, 'driver_opts.type') === 'nfs') {
|
|
continue;
|
|
}
|
|
}
|
|
$slugWithoutUuid = Str::slug($source, '-');
|
|
$name = "{$uuid}_{$slugWithoutUuid}";
|
|
|
|
if (is_string($volume)) {
|
|
$parsed = parseDockerVolumeString($volume);
|
|
$source = $parsed['source'];
|
|
$target = $parsed['target'];
|
|
$source = $name;
|
|
$volume = "$source:$target";
|
|
if (isset($parsed['mode']) && $parsed['mode']) {
|
|
$volume .= ':'.$parsed['mode']->value();
|
|
}
|
|
} elseif (is_array($volume)) {
|
|
data_set($volume, 'source', $name);
|
|
}
|
|
$topLevel->get('volumes')->put($name, [
|
|
'name' => $name,
|
|
]);
|
|
LocalPersistentVolume::updateOrCreate(
|
|
[
|
|
'name' => $name,
|
|
'resource_id' => $originalResource->id,
|
|
'resource_type' => get_class($originalResource),
|
|
],
|
|
[
|
|
'name' => $name,
|
|
'mount_path' => $target,
|
|
'resource_id' => $originalResource->id,
|
|
'resource_type' => get_class($originalResource),
|
|
]
|
|
);
|
|
}
|
|
dispatch(new ServerFilesFromServerJob($originalResource));
|
|
$volumesParsed->put($index, $volume);
|
|
}
|
|
}
|
|
|
|
if (! $use_network_mode) {
|
|
if ($topLevel->get('networks')?->count() > 0) {
|
|
foreach ($topLevel->get('networks') as $networkName => $network) {
|
|
if ($networkName === 'default') {
|
|
continue;
|
|
}
|
|
// ignore aliases
|
|
if ($network['aliases'] ?? false) {
|
|
continue;
|
|
}
|
|
$networkExists = $networks->contains(function ($value, $key) use ($networkName) {
|
|
return $value == $networkName || $key == $networkName;
|
|
});
|
|
if (! $networkExists) {
|
|
$networks->put($networkName, null);
|
|
}
|
|
}
|
|
}
|
|
$baseNetworkExists = $networks->contains(function ($value, $_) use ($baseNetwork) {
|
|
return $value == $baseNetwork;
|
|
});
|
|
if (! $baseNetworkExists) {
|
|
foreach ($baseNetwork as $network) {
|
|
$topLevel->get('networks')->put($network, [
|
|
'name' => $network,
|
|
'external' => true,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect/create/update ports
|
|
$collectedPorts = collect([]);
|
|
if ($ports->count() > 0) {
|
|
foreach ($ports as $sport) {
|
|
if (is_string($sport) || is_numeric($sport)) {
|
|
$collectedPorts->push($sport);
|
|
}
|
|
if (is_array($sport)) {
|
|
$target = data_get($sport, 'target');
|
|
$published = data_get($sport, 'published');
|
|
$protocol = data_get($sport, 'protocol');
|
|
$collectedPorts->push("$target:$published/$protocol");
|
|
}
|
|
}
|
|
}
|
|
$originalResource->ports = $collectedPorts->implode(',');
|
|
$originalResource->save();
|
|
|
|
$networks_temp = collect();
|
|
|
|
if (! $use_network_mode) {
|
|
foreach ($networks as $key => $network) {
|
|
if (gettype($network) === 'string') {
|
|
// networks:
|
|
// - appwrite
|
|
$networks_temp->put($network, null);
|
|
} elseif (gettype($network) === 'array') {
|
|
// networks:
|
|
// default:
|
|
// ipv4_address: 192.168.203.254
|
|
$networks_temp->put($key, $network);
|
|
}
|
|
}
|
|
foreach ($baseNetwork as $key => $network) {
|
|
$networks_temp->put($network, null);
|
|
}
|
|
}
|
|
|
|
$normalEnvironments = $environment->diffKeys($allMagicEnvironments);
|
|
$normalEnvironments = $normalEnvironments->filter(function ($value, $key) {
|
|
return ! str($value)->startsWith('SERVICE_');
|
|
});
|
|
foreach ($normalEnvironments as $key => $value) {
|
|
$key = str($key);
|
|
$value = str($value);
|
|
$originalValue = $value;
|
|
$parsedValue = replaceVariables($value);
|
|
if ($parsedValue->startsWith('SERVICE_')) {
|
|
$resource->environment_variables()->firstOrCreate([
|
|
'key' => $key,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $value,
|
|
'is_preview' => false,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
if (! $value->startsWith('$')) {
|
|
continue;
|
|
}
|
|
if ($key->value() === $parsedValue->value()) {
|
|
$value = null;
|
|
$resource->environment_variables()->firstOrCreate([
|
|
'key' => $key,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $value,
|
|
'is_preview' => false,
|
|
]);
|
|
} else {
|
|
if ($value->startsWith('$')) {
|
|
$isRequired = false;
|
|
if ($value->contains(':-')) {
|
|
$value = replaceVariables($value);
|
|
$key = $value->before(':');
|
|
$value = $value->after(':-');
|
|
} elseif ($value->contains('-')) {
|
|
$value = replaceVariables($value);
|
|
|
|
$key = $value->before('-');
|
|
$value = $value->after('-');
|
|
} elseif ($value->contains(':?')) {
|
|
$value = replaceVariables($value);
|
|
|
|
$key = $value->before(':');
|
|
$value = $value->after(':?');
|
|
$isRequired = true;
|
|
} elseif ($value->contains('?')) {
|
|
$value = replaceVariables($value);
|
|
|
|
$key = $value->before('?');
|
|
$value = $value->after('?');
|
|
$isRequired = true;
|
|
}
|
|
if ($originalValue->value() === $value->value()) {
|
|
// This means the variable does not have a default value, so it needs to be created in Coolify
|
|
$parsedKeyValue = replaceVariables($value);
|
|
$resource->environment_variables()->firstOrCreate([
|
|
'key' => $parsedKeyValue,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'is_preview' => false,
|
|
'is_required' => $isRequired,
|
|
]);
|
|
// Add the variable to the environment so it will be shown in the deployable compose file
|
|
$environment[$parsedKeyValue->value()] = $value;
|
|
|
|
continue;
|
|
}
|
|
$resource->environment_variables()->firstOrCreate([
|
|
'key' => $key,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $value,
|
|
'is_preview' => false,
|
|
'is_required' => $isRequired,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add COOLIFY_RESOURCE_UUID to environment
|
|
if ($resource->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
|
|
$coolifyEnvironments->put('COOLIFY_RESOURCE_UUID', "{$resource->uuid}");
|
|
}
|
|
|
|
// Add COOLIFY_CONTAINER_NAME to environment
|
|
if ($resource->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
|
|
$coolifyEnvironments->put('COOLIFY_CONTAINER_NAME', "{$containerName}");
|
|
}
|
|
|
|
if ($savedService->serviceType()) {
|
|
$fqdns = generateServiceSpecificFqdns($savedService);
|
|
} else {
|
|
$fqdns = collect(data_get($savedService, 'fqdns'))->filter();
|
|
}
|
|
|
|
$defaultLabels = defaultLabels(
|
|
id: $resource->id,
|
|
name: $containerName,
|
|
projectName: $resource->project()->name,
|
|
resourceName: $resource->name,
|
|
type: 'service',
|
|
subType: $isDatabase ? 'database' : 'application',
|
|
subId: $savedService->id,
|
|
subName: $savedService->human_name ?? $savedService->name,
|
|
environment: $resource->environment->name,
|
|
);
|
|
|
|
// Add COOLIFY_FQDN & COOLIFY_URL to environment
|
|
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
|
|
$fqdnsWithoutPort = $fqdns->map(function ($fqdn) {
|
|
return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':');
|
|
});
|
|
$coolifyEnvironments->put('COOLIFY_FQDN', $fqdnsWithoutPort->implode(','));
|
|
$urls = $fqdns->map(function ($fqdn): Stringable {
|
|
return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://'));
|
|
});
|
|
$coolifyEnvironments->put('COOLIFY_URL', $urls->implode(','));
|
|
}
|
|
add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
|
|
if ($environment->count() > 0) {
|
|
$environment = $environment->filter(function ($value, $key) {
|
|
return ! str($key)->startsWith('SERVICE_FQDN_');
|
|
})->map(function ($value, $key) use ($resource) {
|
|
// if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used
|
|
if (str($value)->isEmpty()) {
|
|
if ($resource->environment_variables()->where('key', $key)->exists()) {
|
|
$value = $resource->environment_variables()->where('key', $key)->first()->value;
|
|
} else {
|
|
$value = null;
|
|
}
|
|
}
|
|
|
|
return $value;
|
|
});
|
|
}
|
|
$serviceLabels = $labels->merge($defaultLabels);
|
|
if ($serviceLabels->count() > 0) {
|
|
$isContainerLabelEscapeEnabled = data_get($resource, 'is_container_label_escape_enabled');
|
|
if ($isContainerLabelEscapeEnabled) {
|
|
$serviceLabels = $serviceLabels->map(function ($value, $key) {
|
|
return escapeDollarSign($value);
|
|
});
|
|
}
|
|
}
|
|
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
|
|
$shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels;
|
|
$uuid = $resource->uuid;
|
|
$network = data_get($resource, 'destination.network');
|
|
if ($shouldGenerateLabelsExactly) {
|
|
switch ($server->proxyType()) {
|
|
case ProxyTypes::TRAEFIK->value:
|
|
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
|
|
uuid: $uuid,
|
|
domains: $fqdns,
|
|
is_force_https_enabled: true,
|
|
serviceLabels: $serviceLabels,
|
|
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
|
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
|
service_name: $serviceName,
|
|
image: $image
|
|
));
|
|
break;
|
|
case ProxyTypes::CADDY->value:
|
|
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
|
network: $network,
|
|
uuid: $uuid,
|
|
domains: $fqdns,
|
|
is_force_https_enabled: true,
|
|
serviceLabels: $serviceLabels,
|
|
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
|
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
|
service_name: $serviceName,
|
|
image: $image,
|
|
predefinedPort: $predefinedPort
|
|
));
|
|
break;
|
|
}
|
|
} else {
|
|
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
|
|
uuid: $uuid,
|
|
domains: $fqdns,
|
|
is_force_https_enabled: true,
|
|
serviceLabels: $serviceLabels,
|
|
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
|
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
|
service_name: $serviceName,
|
|
image: $image
|
|
));
|
|
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
|
network: $network,
|
|
uuid: $uuid,
|
|
domains: $fqdns,
|
|
is_force_https_enabled: true,
|
|
serviceLabels: $serviceLabels,
|
|
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
|
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
|
service_name: $serviceName,
|
|
image: $image,
|
|
predefinedPort: $predefinedPort
|
|
));
|
|
}
|
|
}
|
|
if (data_get($service, 'restart') === 'no' || data_get($service, 'exclude_from_hc')) {
|
|
$savedService->update(['exclude_from_status' => true]);
|
|
}
|
|
data_forget($service, 'volumes.*.content');
|
|
data_forget($service, 'volumes.*.isDirectory');
|
|
data_forget($service, 'volumes.*.is_directory');
|
|
data_forget($service, 'exclude_from_hc');
|
|
|
|
$volumesParsed = $volumesParsed->map(function ($volume) {
|
|
data_forget($volume, 'content');
|
|
data_forget($volume, 'is_directory');
|
|
data_forget($volume, 'isDirectory');
|
|
|
|
return $volume;
|
|
});
|
|
|
|
$payload = collect($service)->merge([
|
|
'container_name' => $containerName,
|
|
'restart' => $restart->value(),
|
|
'labels' => $serviceLabels,
|
|
]);
|
|
if (! $use_network_mode) {
|
|
$payload['networks'] = $networks_temp;
|
|
}
|
|
if ($ports->count() > 0) {
|
|
$payload['ports'] = $ports;
|
|
}
|
|
if ($volumesParsed->count() > 0) {
|
|
$payload['volumes'] = $volumesParsed;
|
|
}
|
|
if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) {
|
|
$payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments);
|
|
}
|
|
if ($logging) {
|
|
$payload['logging'] = $logging;
|
|
}
|
|
if ($depends_on->count() > 0) {
|
|
$payload['depends_on'] = $depends_on;
|
|
}
|
|
|
|
$parsedServices->put($serviceName, $payload);
|
|
}
|
|
$topLevel->put('services', $parsedServices);
|
|
|
|
$customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets'];
|
|
|
|
$topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) {
|
|
return array_search($key, $customOrder);
|
|
});
|
|
|
|
$resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2);
|
|
data_forget($resource, 'environment_variables');
|
|
data_forget($resource, 'environment_variables_preview');
|
|
$resource->save();
|
|
|
|
return $topLevel;
|
|
}
|