mirror of
https://github.com/coollabsio/coolify.git
synced 2025-12-28 05:34:50 +00:00
Add defense-in-depth shell argument escaping for repository URL, branch name, and base_directory parameters in the loadConfigFromGit function. While input validation rules already block dangerous characters, escapeshellarg() provides an additional security layer at the function level. Also adds comprehensive unit tests for shell argument escaping behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
3492 lines
143 KiB
PHP
3492 lines
143 KiB
PHP
<?php
|
|
|
|
use App\Enums\ApplicationDeploymentStatus;
|
|
use App\Enums\ProxyTypes;
|
|
use App\Jobs\ServerFilesFromServerJob;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationDeploymentQueue;
|
|
use App\Models\ApplicationPreview;
|
|
use App\Models\EnvironmentVariable;
|
|
use App\Models\GithubApp;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\LocalFileVolume;
|
|
use App\Models\LocalPersistentVolume;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceApplication;
|
|
use App\Models\ServiceDatabase;
|
|
use App\Models\StandaloneClickhouse;
|
|
use App\Models\StandaloneDragonfly;
|
|
use App\Models\StandaloneKeydb;
|
|
use App\Models\StandaloneMariadb;
|
|
use App\Models\StandaloneMongodb;
|
|
use App\Models\StandaloneMysql;
|
|
use App\Models\StandalonePostgresql;
|
|
use App\Models\StandaloneRedis;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Carbon\CarbonImmutable;
|
|
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
|
|
use Illuminate\Database\UniqueConstraintViolationException;
|
|
use Illuminate\Process\Pool;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Illuminate\Support\Facades\Request;
|
|
use Illuminate\Support\Facades\Route;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Support\Stringable;
|
|
use Laravel\Horizon\Contracts\JobRepository;
|
|
use Lcobucci\JWT\Encoding\ChainedFormatter;
|
|
use Lcobucci\JWT\Encoding\JoseEncoder;
|
|
use Lcobucci\JWT\Signer\Hmac\Sha256;
|
|
use Lcobucci\JWT\Signer\Key\InMemory;
|
|
use Lcobucci\JWT\Token\Builder;
|
|
use phpseclib3\Crypt\EC;
|
|
use phpseclib3\Crypt\RSA;
|
|
use Poliander\Cron\CronExpression;
|
|
use PurplePixie\PhpDns\DNSQuery;
|
|
use Spatie\Url\Url;
|
|
use Symfony\Component\Yaml\Yaml;
|
|
use Visus\Cuid2\Cuid2;
|
|
|
|
function base_configuration_dir(): string
|
|
{
|
|
return '/data/coolify';
|
|
}
|
|
function application_configuration_dir(): string
|
|
{
|
|
return base_configuration_dir().'/applications';
|
|
}
|
|
function service_configuration_dir(): string
|
|
{
|
|
return base_configuration_dir().'/services';
|
|
}
|
|
function database_configuration_dir(): string
|
|
{
|
|
return base_configuration_dir().'/databases';
|
|
}
|
|
function database_proxy_dir($uuid): string
|
|
{
|
|
return base_configuration_dir()."/databases/$uuid/proxy";
|
|
}
|
|
function backup_dir(): string
|
|
{
|
|
return base_configuration_dir().'/backups';
|
|
}
|
|
function metrics_dir(): string
|
|
{
|
|
return base_configuration_dir().'/metrics';
|
|
}
|
|
|
|
function sanitize_string(?string $input = null): ?string
|
|
{
|
|
if (is_null($input)) {
|
|
return null;
|
|
}
|
|
// Remove any HTML/PHP tags
|
|
$sanitized = strip_tags($input);
|
|
|
|
// Convert special characters to HTML entities
|
|
$sanitized = htmlspecialchars($sanitized, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
|
|
// Remove any control characters
|
|
$sanitized = preg_replace('/[\x00-\x1F\x7F]/u', '', $sanitized);
|
|
|
|
// Trim whitespace
|
|
$sanitized = trim($sanitized);
|
|
|
|
return $sanitized;
|
|
}
|
|
|
|
/**
|
|
* Validate that a path or identifier is safe for use in shell commands.
|
|
*
|
|
* This function prevents command injection by rejecting strings that contain
|
|
* shell metacharacters or command substitution patterns.
|
|
*
|
|
* @param string $input The path or identifier to validate
|
|
* @param string $context Descriptive name for error messages (e.g., 'volume source', 'service name')
|
|
* @return string The validated input (unchanged if valid)
|
|
*
|
|
* @throws \Exception If dangerous characters are detected
|
|
*/
|
|
function validateShellSafePath(string $input, string $context = 'path'): string
|
|
{
|
|
// List of dangerous shell metacharacters that enable command injection
|
|
$dangerousChars = [
|
|
'`' => 'backtick (command substitution)',
|
|
'$(' => 'command substitution',
|
|
'${' => 'variable substitution with potential command injection',
|
|
'|' => 'pipe operator',
|
|
'&' => 'background/AND operator',
|
|
';' => 'command separator',
|
|
"\n" => 'newline (command separator)',
|
|
"\r" => 'carriage return',
|
|
"\t" => 'tab (token separator)',
|
|
'>' => 'output redirection',
|
|
'<' => 'input redirection',
|
|
];
|
|
|
|
// Check for dangerous characters
|
|
foreach ($dangerousChars as $char => $description) {
|
|
if (str_contains($input, $char)) {
|
|
throw new \Exception(
|
|
"Invalid {$context}: contains forbidden character '{$char}' ({$description}). ".
|
|
'Shell metacharacters are not allowed for security reasons.'
|
|
);
|
|
}
|
|
}
|
|
|
|
return $input;
|
|
}
|
|
|
|
function generate_readme_file(string $name, string $updated_at): string
|
|
{
|
|
$name = sanitize_string($name);
|
|
$updated_at = sanitize_string($updated_at);
|
|
|
|
return "Resource name: $name\nLatest Deployment Date: $updated_at";
|
|
}
|
|
|
|
function isInstanceAdmin()
|
|
{
|
|
return auth()?->user()?->isInstanceAdmin() ?? false;
|
|
}
|
|
|
|
function currentTeam()
|
|
{
|
|
return Auth::user()?->currentTeam() ?? null;
|
|
}
|
|
|
|
function showBoarding(): bool
|
|
{
|
|
if (Auth::user()?->isMember()) {
|
|
return false;
|
|
}
|
|
|
|
return currentTeam()->show_boarding ?? false;
|
|
}
|
|
function refreshSession(?Team $team = null): void
|
|
{
|
|
if (! $team) {
|
|
if (Auth::user()->currentTeam()) {
|
|
$team = Team::find(Auth::user()->currentTeam()->id);
|
|
} else {
|
|
$team = User::find(Auth::id())->teams->first();
|
|
}
|
|
}
|
|
Cache::forget('team:'.Auth::id());
|
|
Cache::remember('team:'.Auth::id(), 3600, function () use ($team) {
|
|
return $team;
|
|
});
|
|
session(['currentTeam' => $team]);
|
|
}
|
|
function handleError(?Throwable $error = null, ?Livewire\Component $livewire = null, ?string $customErrorMessage = null)
|
|
{
|
|
if ($error instanceof TooManyRequestsException) {
|
|
if (isset($livewire)) {
|
|
return $livewire->dispatch('error', "Too many requests. Please try again in {$error->secondsUntilAvailable} seconds.");
|
|
}
|
|
|
|
return "Too many requests. Please try again in {$error->secondsUntilAvailable} seconds.";
|
|
}
|
|
if ($error instanceof UniqueConstraintViolationException) {
|
|
if (isset($livewire)) {
|
|
return $livewire->dispatch('error', 'Duplicate entry found. Please use a different name.');
|
|
}
|
|
|
|
return 'Duplicate entry found. Please use a different name.';
|
|
}
|
|
|
|
if ($error instanceof \Illuminate\Database\Eloquent\ModelNotFoundException) {
|
|
abort(404);
|
|
}
|
|
|
|
if ($error instanceof Throwable) {
|
|
$message = $error->getMessage();
|
|
} else {
|
|
$message = null;
|
|
}
|
|
if ($customErrorMessage) {
|
|
$message = $customErrorMessage.' '.$message;
|
|
}
|
|
|
|
if (isset($livewire)) {
|
|
return $livewire->dispatch('error', $message);
|
|
}
|
|
throw new Exception($message);
|
|
}
|
|
function get_route_parameters(): array
|
|
{
|
|
return Route::current()->parameters();
|
|
}
|
|
|
|
function get_latest_sentinel_version(): string
|
|
{
|
|
try {
|
|
$response = Http::get(config('constants.coolify.versions_url'));
|
|
$versions = $response->json();
|
|
|
|
return data_get($versions, 'coolify.sentinel.version');
|
|
} catch (\Throwable) {
|
|
return '0.0.0';
|
|
}
|
|
}
|
|
function get_latest_version_of_coolify(): string
|
|
{
|
|
try {
|
|
$versions = get_versions_data();
|
|
|
|
return data_get($versions, 'coolify.v4.version', '0.0.0');
|
|
} catch (\Throwable $e) {
|
|
|
|
return '0.0.0';
|
|
}
|
|
}
|
|
|
|
function generate_random_name(?string $cuid = null): string
|
|
{
|
|
$generator = new \Nubs\RandomNameGenerator\All(
|
|
[
|
|
new \Nubs\RandomNameGenerator\Alliteration,
|
|
]
|
|
);
|
|
if (is_null($cuid)) {
|
|
$cuid = new Cuid2;
|
|
}
|
|
|
|
return Str::kebab("{$generator->getName()}-$cuid");
|
|
}
|
|
function generateSSHKey(string $type = 'rsa')
|
|
{
|
|
if ($type === 'rsa') {
|
|
$key = RSA::createKey();
|
|
|
|
return [
|
|
'private' => $key->toString('PKCS1'),
|
|
'public' => $key->getPublicKey()->toString('OpenSSH', ['comment' => 'coolify-generated-ssh-key']),
|
|
];
|
|
} elseif ($type === 'ed25519') {
|
|
$key = EC::createKey('Ed25519');
|
|
|
|
return [
|
|
'private' => $key->toString('OpenSSH'),
|
|
'public' => $key->getPublicKey()->toString('OpenSSH', ['comment' => 'coolify-generated-ssh-key']),
|
|
];
|
|
}
|
|
throw new Exception('Invalid key type');
|
|
}
|
|
function formatPrivateKey(string $privateKey)
|
|
{
|
|
$privateKey = trim($privateKey);
|
|
if (! str_ends_with($privateKey, "\n")) {
|
|
$privateKey .= "\n";
|
|
}
|
|
|
|
return $privateKey;
|
|
}
|
|
function generate_application_name(string $git_repository, string $git_branch, ?string $cuid = null): string
|
|
{
|
|
if (is_null($cuid)) {
|
|
$cuid = new Cuid2;
|
|
}
|
|
|
|
return Str::kebab("$git_repository:$git_branch-$cuid");
|
|
}
|
|
|
|
/**
|
|
* Sort branches by priority: main first, master second, then alphabetically.
|
|
*
|
|
* @param Collection $branches Collection of branch objects with 'name' key
|
|
*/
|
|
function sortBranchesByPriority(Collection $branches): Collection
|
|
{
|
|
return $branches->sortBy(function ($branch) {
|
|
$name = data_get($branch, 'name');
|
|
|
|
return match ($name) {
|
|
'main' => '0_main',
|
|
'master' => '1_master',
|
|
default => '2_'.$name,
|
|
};
|
|
})->values();
|
|
}
|
|
|
|
function base_ip(): string
|
|
{
|
|
if (isDev()) {
|
|
return 'localhost';
|
|
}
|
|
$settings = instanceSettings();
|
|
if ($settings->public_ipv4) {
|
|
return "$settings->public_ipv4";
|
|
}
|
|
if ($settings->public_ipv6) {
|
|
return "$settings->public_ipv6";
|
|
}
|
|
|
|
return 'localhost';
|
|
}
|
|
function getFqdnWithoutPort(string $fqdn)
|
|
{
|
|
try {
|
|
$url = Url::fromString($fqdn);
|
|
$host = $url->getHost();
|
|
$scheme = $url->getScheme();
|
|
$path = $url->getPath();
|
|
|
|
return "$scheme://$host$path";
|
|
} catch (\Throwable) {
|
|
return $fqdn;
|
|
}
|
|
}
|
|
/**
|
|
* If fqdn is set, return it, otherwise return public ip.
|
|
*/
|
|
function base_url(bool $withPort = true): string
|
|
{
|
|
$settings = instanceSettings();
|
|
if ($settings->fqdn) {
|
|
return $settings->fqdn;
|
|
}
|
|
$port = config('app.port');
|
|
if ($settings->public_ipv4) {
|
|
if ($withPort) {
|
|
if (isDev()) {
|
|
return "http://localhost:$port";
|
|
}
|
|
|
|
return "http://$settings->public_ipv4:$port";
|
|
}
|
|
if (isDev()) {
|
|
return 'http://localhost';
|
|
}
|
|
|
|
return "http://$settings->public_ipv4";
|
|
}
|
|
if ($settings->public_ipv6) {
|
|
if ($withPort) {
|
|
return "http://$settings->public_ipv6:$port";
|
|
}
|
|
|
|
return "http://$settings->public_ipv6";
|
|
}
|
|
|
|
return url('/');
|
|
}
|
|
|
|
function isSubscribed()
|
|
{
|
|
return isSubscriptionActive() || auth()->user()->isInstanceAdmin();
|
|
}
|
|
|
|
function isProduction(): bool
|
|
{
|
|
return ! isDev();
|
|
}
|
|
function isDev(): bool
|
|
{
|
|
return config('app.env') === 'local';
|
|
}
|
|
|
|
function isCloud(): bool
|
|
{
|
|
return ! config('constants.coolify.self_hosted');
|
|
}
|
|
|
|
function translate_cron_expression($expression_to_validate): string
|
|
{
|
|
if (isset(VALID_CRON_STRINGS[$expression_to_validate])) {
|
|
return VALID_CRON_STRINGS[$expression_to_validate];
|
|
}
|
|
|
|
return $expression_to_validate;
|
|
}
|
|
function validate_cron_expression($expression_to_validate): bool
|
|
{
|
|
if (empty($expression_to_validate)) {
|
|
return false;
|
|
}
|
|
$isValid = false;
|
|
$expression = new CronExpression($expression_to_validate);
|
|
$isValid = $expression->isValid();
|
|
|
|
if (isset(VALID_CRON_STRINGS[$expression_to_validate])) {
|
|
$isValid = true;
|
|
}
|
|
|
|
return $isValid;
|
|
}
|
|
|
|
function validate_timezone(string $timezone): bool
|
|
{
|
|
return in_array($timezone, timezone_identifiers_list());
|
|
}
|
|
|
|
function parseEnvFormatToArray($env_file_contents)
|
|
{
|
|
$env_array = [];
|
|
$lines = explode("\n", $env_file_contents);
|
|
foreach ($lines as $line) {
|
|
if ($line === '' || substr($line, 0, 1) === '#') {
|
|
continue;
|
|
}
|
|
$equals_pos = strpos($line, '=');
|
|
if ($equals_pos !== false) {
|
|
$key = substr($line, 0, $equals_pos);
|
|
$value = substr($line, $equals_pos + 1);
|
|
if (substr($value, 0, 1) === '"' && substr($value, -1) === '"') {
|
|
$value = substr($value, 1, -1);
|
|
} elseif (substr($value, 0, 1) === "'" && substr($value, -1) === "'") {
|
|
$value = substr($value, 1, -1);
|
|
}
|
|
$env_array[$key] = $value;
|
|
}
|
|
}
|
|
|
|
return $env_array;
|
|
}
|
|
|
|
function data_get_str($data, $key, $default = null): Stringable
|
|
{
|
|
$str = data_get($data, $key, $default) ?? $default;
|
|
|
|
return str($str);
|
|
}
|
|
|
|
function generateUrl(Server $server, string $random, bool $forceHttps = false): string
|
|
{
|
|
$wildcard = data_get($server, 'settings.wildcard_domain');
|
|
if (is_null($wildcard) || $wildcard === '') {
|
|
$wildcard = sslip($server);
|
|
}
|
|
$url = Url::fromString($wildcard);
|
|
$host = $url->getHost();
|
|
$path = $url->getPath() === '/' ? '' : $url->getPath();
|
|
$scheme = $url->getScheme();
|
|
if ($forceHttps) {
|
|
$scheme = 'https';
|
|
}
|
|
|
|
return "$scheme://{$random}.$host$path";
|
|
}
|
|
function generateFqdn(Server $server, string $random, bool $forceHttps = false, int $parserVersion = 5): string
|
|
{
|
|
|
|
$wildcard = data_get($server, 'settings.wildcard_domain');
|
|
if (is_null($wildcard) || $wildcard === '') {
|
|
$wildcard = sslip($server);
|
|
}
|
|
$url = Url::fromString($wildcard);
|
|
$host = $url->getHost();
|
|
$path = $url->getPath() === '/' ? '' : $url->getPath();
|
|
$scheme = $url->getScheme();
|
|
if ($forceHttps) {
|
|
$scheme = 'https';
|
|
}
|
|
|
|
if ($parserVersion >= 5 && version_compare(config('constants.coolify.version'), '4.0.0-beta.420.7', '>=')) {
|
|
return "{$random}.$host$path";
|
|
}
|
|
|
|
return "$scheme://{$random}.$host$path";
|
|
}
|
|
function sslip(Server $server)
|
|
{
|
|
if (isDev() && $server->id === 0) {
|
|
return 'http://127.0.0.1.sslip.io';
|
|
}
|
|
if ($server->ip === 'host.docker.internal') {
|
|
$baseIp = base_ip();
|
|
|
|
return "http://$baseIp.sslip.io";
|
|
}
|
|
// ipv6
|
|
if (str($server->ip)->contains(':')) {
|
|
$ipv6 = str($server->ip)->replace(':', '-');
|
|
|
|
return "http://{$ipv6}.sslip.io";
|
|
}
|
|
|
|
return "http://{$server->ip}.sslip.io";
|
|
}
|
|
|
|
function get_service_templates(bool $force = false): Collection
|
|
{
|
|
|
|
if ($force) {
|
|
try {
|
|
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
|
|
if ($response->failed()) {
|
|
return collect([]);
|
|
}
|
|
$services = $response->json();
|
|
|
|
return collect($services);
|
|
} catch (\Throwable) {
|
|
$services = File::get(base_path('templates/'.config('constants.services.file_name')));
|
|
|
|
return collect(json_decode($services))->sortKeys();
|
|
}
|
|
} else {
|
|
$services = File::get(base_path('templates/'.config('constants.services.file_name')));
|
|
|
|
return collect(json_decode($services))->sortKeys();
|
|
}
|
|
}
|
|
|
|
function getResourceByUuid(string $uuid, ?int $teamId = null)
|
|
{
|
|
if (is_null($teamId)) {
|
|
return null;
|
|
}
|
|
$resource = queryResourcesByUuid($uuid);
|
|
if (! is_null($resource) && $resource->environment->project->team_id === $teamId) {
|
|
return $resource;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
function queryDatabaseByUuidWithinTeam(string $uuid, string $teamId)
|
|
{
|
|
$postgresql = StandalonePostgresql::whereUuid($uuid)->first();
|
|
if ($postgresql && $postgresql->team()->id == $teamId) {
|
|
return $postgresql->unsetRelation('environment');
|
|
}
|
|
$redis = StandaloneRedis::whereUuid($uuid)->first();
|
|
if ($redis && $redis->team()->id == $teamId) {
|
|
return $redis->unsetRelation('environment');
|
|
}
|
|
$mongodb = StandaloneMongodb::whereUuid($uuid)->first();
|
|
if ($mongodb && $mongodb->team()->id == $teamId) {
|
|
return $mongodb->unsetRelation('environment');
|
|
}
|
|
$mysql = StandaloneMysql::whereUuid($uuid)->first();
|
|
if ($mysql && $mysql->team()->id == $teamId) {
|
|
return $mysql->unsetRelation('environment');
|
|
}
|
|
$mariadb = StandaloneMariadb::whereUuid($uuid)->first();
|
|
if ($mariadb && $mariadb->team()->id == $teamId) {
|
|
return $mariadb->unsetRelation('environment');
|
|
}
|
|
$keydb = StandaloneKeydb::whereUuid($uuid)->first();
|
|
if ($keydb && $keydb->team()->id == $teamId) {
|
|
return $keydb->unsetRelation('environment');
|
|
}
|
|
$dragonfly = StandaloneDragonfly::whereUuid($uuid)->first();
|
|
if ($dragonfly && $dragonfly->team()->id == $teamId) {
|
|
return $dragonfly->unsetRelation('environment');
|
|
}
|
|
$clickhouse = StandaloneClickhouse::whereUuid($uuid)->first();
|
|
if ($clickhouse && $clickhouse->team()->id == $teamId) {
|
|
return $clickhouse->unsetRelation('environment');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
function queryResourcesByUuid(string $uuid)
|
|
{
|
|
$resource = null;
|
|
$application = Application::whereUuid($uuid)->first();
|
|
if ($application) {
|
|
return $application;
|
|
}
|
|
$service = Service::whereUuid($uuid)->first();
|
|
if ($service) {
|
|
return $service;
|
|
}
|
|
$postgresql = StandalonePostgresql::whereUuid($uuid)->first();
|
|
if ($postgresql) {
|
|
return $postgresql;
|
|
}
|
|
$redis = StandaloneRedis::whereUuid($uuid)->first();
|
|
if ($redis) {
|
|
return $redis;
|
|
}
|
|
$mongodb = StandaloneMongodb::whereUuid($uuid)->first();
|
|
if ($mongodb) {
|
|
return $mongodb;
|
|
}
|
|
$mysql = StandaloneMysql::whereUuid($uuid)->first();
|
|
if ($mysql) {
|
|
return $mysql;
|
|
}
|
|
$mariadb = StandaloneMariadb::whereUuid($uuid)->first();
|
|
if ($mariadb) {
|
|
return $mariadb;
|
|
}
|
|
$keydb = StandaloneKeydb::whereUuid($uuid)->first();
|
|
if ($keydb) {
|
|
return $keydb;
|
|
}
|
|
$dragonfly = StandaloneDragonfly::whereUuid($uuid)->first();
|
|
if ($dragonfly) {
|
|
return $dragonfly;
|
|
}
|
|
$clickhouse = StandaloneClickhouse::whereUuid($uuid)->first();
|
|
if ($clickhouse) {
|
|
return $clickhouse;
|
|
}
|
|
|
|
return $resource;
|
|
}
|
|
function generateTagDeployWebhook($tag_name)
|
|
{
|
|
$baseUrl = base_url();
|
|
$api = Url::fromString($baseUrl).'/api/v1';
|
|
$endpoint = "/deploy?tag=$tag_name";
|
|
|
|
return $api.$endpoint;
|
|
}
|
|
function generateDeployWebhook($resource)
|
|
{
|
|
$baseUrl = base_url();
|
|
$api = Url::fromString($baseUrl).'/api/v1';
|
|
$endpoint = '/deploy';
|
|
$uuid = data_get($resource, 'uuid');
|
|
|
|
return $api.$endpoint."?uuid=$uuid&force=false";
|
|
}
|
|
function generateGitManualWebhook($resource, $type)
|
|
{
|
|
if ($resource->source_id !== 0 && ! is_null($resource->source_id)) {
|
|
return null;
|
|
}
|
|
if ($resource->getMorphClass() === \App\Models\Application::class) {
|
|
$baseUrl = base_url();
|
|
|
|
return Url::fromString($baseUrl)."/webhooks/source/$type/events/manual";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
function removeAnsiColors($text)
|
|
{
|
|
return preg_replace('/\e[[][A-Za-z0-9];?[0-9]*m?/', '', $text);
|
|
}
|
|
|
|
function sanitizeLogsForExport(string $text): string
|
|
{
|
|
// All sanitization is now handled by remove_iip()
|
|
return remove_iip($text);
|
|
}
|
|
|
|
function getTopLevelNetworks(Service|Application $resource)
|
|
{
|
|
if ($resource->getMorphClass() === \App\Models\Service::class) {
|
|
if ($resource->docker_compose_raw) {
|
|
try {
|
|
$yaml = Yaml::parse($resource->docker_compose_raw);
|
|
} catch (\Exception $e) {
|
|
// If the docker-compose.yml file is not valid, we will return the network name as the key
|
|
$topLevelNetworks = collect([
|
|
$resource->uuid => [
|
|
'name' => $resource->uuid,
|
|
'external' => true,
|
|
],
|
|
]);
|
|
|
|
return $topLevelNetworks->keys();
|
|
}
|
|
$services = data_get($yaml, 'services');
|
|
$topLevelNetworks = collect(data_get($yaml, 'networks', []));
|
|
$definedNetwork = collect([$resource->uuid]);
|
|
$services = collect($services)->map(function ($service, $_) use ($topLevelNetworks, $definedNetwork) {
|
|
$serviceNetworks = collect(data_get($service, 'networks', []));
|
|
$networkMode = data_get($service, 'network_mode');
|
|
|
|
$hasValidNetworkMode =
|
|
$networkMode === 'host' ||
|
|
(is_string($networkMode) && (str_starts_with($networkMode, 'service:') || str_starts_with($networkMode, 'container:')));
|
|
|
|
// Only add 'networks' key if 'network_mode' is not 'host' or does not start with 'service:' or 'container:'
|
|
if (! $hasValidNetworkMode) {
|
|
// Collect/create/update networks
|
|
if ($serviceNetworks->count() > 0) {
|
|
foreach ($serviceNetworks as $networkName => $networkDetails) {
|
|
if ($networkName === 'default') {
|
|
continue;
|
|
}
|
|
// ignore alias
|
|
if ($networkDetails['aliases'] ?? false) {
|
|
continue;
|
|
}
|
|
$networkExists = $topLevelNetworks->contains(function ($value, $key) use ($networkName) {
|
|
return $value == $networkName || $key == $networkName;
|
|
});
|
|
if (! $networkExists) {
|
|
if (is_string($networkDetails) || is_int($networkDetails)) {
|
|
$topLevelNetworks->put($networkDetails, null);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$definedNetworkExists = $topLevelNetworks->contains(function ($value, $_) use ($definedNetwork) {
|
|
return $value == $definedNetwork;
|
|
});
|
|
if (! $definedNetworkExists) {
|
|
foreach ($definedNetwork as $network) {
|
|
$topLevelNetworks->put($network, [
|
|
'name' => $network,
|
|
'external' => true,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $service;
|
|
});
|
|
|
|
return $topLevelNetworks->keys();
|
|
}
|
|
} elseif ($resource->getMorphClass() === \App\Models\Application::class) {
|
|
try {
|
|
$yaml = Yaml::parse($resource->docker_compose_raw);
|
|
} catch (\Exception $e) {
|
|
// If the docker-compose.yml file is not valid, we will return the network name as the key
|
|
$topLevelNetworks = collect([
|
|
$resource->uuid => [
|
|
'name' => $resource->uuid,
|
|
'external' => true,
|
|
],
|
|
]);
|
|
|
|
return $topLevelNetworks->keys();
|
|
}
|
|
$topLevelNetworks = collect(data_get($yaml, 'networks', []));
|
|
$services = data_get($yaml, 'services');
|
|
$definedNetwork = collect([$resource->uuid]);
|
|
$services = collect($services)->map(function ($service, $_) use ($topLevelNetworks, $definedNetwork) {
|
|
$serviceNetworks = collect(data_get($service, 'networks', []));
|
|
|
|
// Collect/create/update networks
|
|
if ($serviceNetworks->count() > 0) {
|
|
foreach ($serviceNetworks as $networkName => $networkDetails) {
|
|
if ($networkName === 'default') {
|
|
continue;
|
|
}
|
|
// ignore alias
|
|
if ($networkDetails['aliases'] ?? false) {
|
|
continue;
|
|
}
|
|
$networkExists = $topLevelNetworks->contains(function ($value, $key) use ($networkName) {
|
|
return $value == $networkName || $key == $networkName;
|
|
});
|
|
if (! $networkExists) {
|
|
if (is_string($networkDetails) || is_int($networkDetails)) {
|
|
$topLevelNetworks->put($networkDetails, null);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
$definedNetworkExists = $topLevelNetworks->contains(function ($value, $_) use ($definedNetwork) {
|
|
return $value == $definedNetwork;
|
|
});
|
|
if (! $definedNetworkExists) {
|
|
foreach ($definedNetwork as $network) {
|
|
$topLevelNetworks->put($network, [
|
|
'name' => $network,
|
|
'external' => true,
|
|
]);
|
|
}
|
|
}
|
|
|
|
return $service;
|
|
});
|
|
|
|
return $topLevelNetworks->keys();
|
|
}
|
|
}
|
|
function sourceIsLocal(Stringable $source)
|
|
{
|
|
if ($source->startsWith('./') || $source->startsWith('/') || $source->startsWith('~') || $source->startsWith('..') || $source->startsWith('~/') || $source->startsWith('../')) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function replaceLocalSource(Stringable $source, Stringable $replacedWith)
|
|
{
|
|
if ($source->startsWith('.')) {
|
|
$source = $source->replaceFirst('.', $replacedWith->value());
|
|
}
|
|
if ($source->startsWith('~')) {
|
|
$source = $source->replaceFirst('~', $replacedWith->value());
|
|
}
|
|
if ($source->startsWith('..')) {
|
|
$source = $source->replaceFirst('..', $replacedWith->value());
|
|
}
|
|
if ($source->endsWith('/') && $source->value() !== '/') {
|
|
$source = $source->replaceLast('/', '');
|
|
}
|
|
|
|
return $source;
|
|
}
|
|
|
|
function convertToArray($collection)
|
|
{
|
|
if ($collection instanceof Collection) {
|
|
return $collection->map(function ($item) {
|
|
return convertToArray($item);
|
|
})->toArray();
|
|
} elseif ($collection instanceof Stringable) {
|
|
return (string) $collection;
|
|
} elseif (is_array($collection)) {
|
|
return array_map(function ($item) {
|
|
return convertToArray($item);
|
|
}, $collection);
|
|
}
|
|
|
|
return $collection;
|
|
}
|
|
|
|
function parseCommandFromMagicEnvVariable(Str|string $key): Stringable
|
|
{
|
|
$value = str($key);
|
|
$count = substr_count($value->value(), '_');
|
|
$command = null;
|
|
if ($count === 2) {
|
|
if ($value->startsWith('SERVICE_FQDN') || $value->startsWith('SERVICE_URL')) {
|
|
// SERVICE_FQDN_UMAMI
|
|
$command = $value->after('SERVICE_')->beforeLast('_');
|
|
} else {
|
|
// SERVICE_BASE64_UMAMI
|
|
$command = $value->after('SERVICE_')->beforeLast('_');
|
|
}
|
|
}
|
|
if ($count === 3) {
|
|
if ($value->startsWith('SERVICE_FQDN') || $value->startsWith('SERVICE_URL')) {
|
|
// SERVICE_FQDN_UMAMI_1000
|
|
$command = $value->after('SERVICE_')->before('_');
|
|
} else {
|
|
// SERVICE_BASE64_64_UMAMI
|
|
$command = $value->after('SERVICE_')->beforeLast('_');
|
|
}
|
|
}
|
|
|
|
return str($command);
|
|
}
|
|
function parseEnvVariable(Str|string $value)
|
|
{
|
|
$value = str($value);
|
|
$count = substr_count($value->value(), '_');
|
|
$command = null;
|
|
$forService = null;
|
|
$generatedValue = null;
|
|
$port = null;
|
|
if ($value->startsWith('SERVICE')) {
|
|
if ($count === 2) {
|
|
if ($value->startsWith('SERVICE_FQDN') || $value->startsWith('SERVICE_URL')) {
|
|
// SERVICE_FQDN_UMAMI
|
|
$command = $value->after('SERVICE_')->beforeLast('_');
|
|
$forService = $value->afterLast('_');
|
|
} else {
|
|
// SERVICE_BASE64_UMAMI
|
|
$command = $value->after('SERVICE_')->beforeLast('_');
|
|
}
|
|
}
|
|
if ($count === 3) {
|
|
if ($value->startsWith('SERVICE_FQDN') || $value->startsWith('SERVICE_URL')) {
|
|
// SERVICE_FQDN_UMAMI_1000
|
|
$command = $value->after('SERVICE_')->before('_');
|
|
$forService = $value->after('SERVICE_')->after('_')->before('_');
|
|
$port = $value->afterLast('_');
|
|
if (filter_var($port, FILTER_VALIDATE_INT) === false) {
|
|
$port = null;
|
|
}
|
|
} else {
|
|
// SERVICE_BASE64_64_UMAMI
|
|
$command = $value->after('SERVICE_')->beforeLast('_');
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'command' => $command,
|
|
'forService' => $forService,
|
|
'generatedValue' => $generatedValue,
|
|
'port' => $port,
|
|
];
|
|
}
|
|
function generateEnvValue(string $command, Service|Application|null $service = null)
|
|
{
|
|
switch ($command) {
|
|
case 'PASSWORD':
|
|
$generatedValue = Str::password(symbols: false);
|
|
break;
|
|
case 'PASSWORD_64':
|
|
$generatedValue = Str::password(length: 64, symbols: false);
|
|
break;
|
|
case 'PASSWORDWITHSYMBOLS':
|
|
$generatedValue = Str::password(symbols: true);
|
|
break;
|
|
case 'PASSWORDWITHSYMBOLS_64':
|
|
$generatedValue = Str::password(length: 64, symbols: true);
|
|
break;
|
|
// This is not base64, it's just a random string
|
|
case 'BASE64_64':
|
|
$generatedValue = Str::random(64);
|
|
break;
|
|
case 'BASE64_128':
|
|
$generatedValue = Str::random(128);
|
|
break;
|
|
case 'BASE64':
|
|
case 'BASE64_32':
|
|
$generatedValue = Str::random(32);
|
|
break;
|
|
// This is base64,
|
|
case 'REALBASE64_64':
|
|
$generatedValue = base64_encode(Str::random(64));
|
|
break;
|
|
case 'REALBASE64_128':
|
|
$generatedValue = base64_encode(Str::random(128));
|
|
break;
|
|
case 'REALBASE64':
|
|
case 'REALBASE64_32':
|
|
$generatedValue = base64_encode(Str::random(32));
|
|
break;
|
|
case 'HEX_32':
|
|
$generatedValue = bin2hex(Str::random(32));
|
|
break;
|
|
case 'HEX_64':
|
|
$generatedValue = bin2hex(Str::random(64));
|
|
break;
|
|
case 'HEX_128':
|
|
$generatedValue = bin2hex(Str::random(128));
|
|
break;
|
|
case 'USER':
|
|
$generatedValue = Str::random(16);
|
|
break;
|
|
case 'SUPABASEANON':
|
|
$signingKey = $service->environment_variables()->where('key', 'SERVICE_PASSWORD_JWT')->first();
|
|
if (is_null($signingKey)) {
|
|
return;
|
|
} else {
|
|
$signingKey = $signingKey->value;
|
|
}
|
|
$key = InMemory::plainText($signingKey);
|
|
$algorithm = new Sha256;
|
|
$tokenBuilder = (new Builder(new JoseEncoder, ChainedFormatter::default()));
|
|
$now = CarbonImmutable::now();
|
|
$now = $now->setTime($now->format('H'), $now->format('i'));
|
|
$token = $tokenBuilder
|
|
->issuedBy('supabase')
|
|
->issuedAt($now)
|
|
->expiresAt($now->modify('+100 year'))
|
|
->withClaim('role', 'anon')
|
|
->getToken($algorithm, $key);
|
|
$generatedValue = $token->toString();
|
|
break;
|
|
case 'SUPABASESERVICE':
|
|
$signingKey = $service->environment_variables()->where('key', 'SERVICE_PASSWORD_JWT')->first();
|
|
if (is_null($signingKey)) {
|
|
return;
|
|
} else {
|
|
$signingKey = $signingKey->value;
|
|
}
|
|
$key = InMemory::plainText($signingKey);
|
|
$algorithm = new Sha256;
|
|
$tokenBuilder = (new Builder(new JoseEncoder, ChainedFormatter::default()));
|
|
$now = CarbonImmutable::now();
|
|
$now = $now->setTime($now->format('H'), $now->format('i'));
|
|
$token = $tokenBuilder
|
|
->issuedBy('supabase')
|
|
->issuedAt($now)
|
|
->expiresAt($now->modify('+100 year'))
|
|
->withClaim('role', 'service_role')
|
|
->getToken($algorithm, $key);
|
|
$generatedValue = $token->toString();
|
|
break;
|
|
default:
|
|
// $generatedValue = Str::random(16);
|
|
$generatedValue = null;
|
|
break;
|
|
}
|
|
|
|
return $generatedValue;
|
|
}
|
|
|
|
function getRealtime()
|
|
{
|
|
$envDefined = config('constants.pusher.port');
|
|
if (empty($envDefined)) {
|
|
$url = Url::fromString(Request::getSchemeAndHttpHost());
|
|
$port = $url->getPort();
|
|
if ($port) {
|
|
return '6001';
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
return $envDefined;
|
|
}
|
|
}
|
|
|
|
function validateDNSEntry(string $fqdn, Server $server)
|
|
{
|
|
// https://www.cloudflare.com/ips-v4/#
|
|
$cloudflare_ips = collect(['173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13', '172.64.0.0/13', '131.0.72.0/22']);
|
|
|
|
$url = Url::fromString($fqdn);
|
|
$host = $url->getHost();
|
|
if (str($host)->contains('sslip.io')) {
|
|
return true;
|
|
}
|
|
$settings = instanceSettings();
|
|
$is_dns_validation_enabled = data_get($settings, 'is_dns_validation_enabled');
|
|
if (! $is_dns_validation_enabled) {
|
|
return true;
|
|
}
|
|
$dns_servers = data_get($settings, 'custom_dns_servers');
|
|
$dns_servers = str($dns_servers)->explode(',');
|
|
if ($server->id === 0) {
|
|
$ip = data_get($settings, 'public_ipv4', data_get($settings, 'public_ipv6', $server->ip));
|
|
} else {
|
|
$ip = $server->ip;
|
|
}
|
|
$found_matching_ip = false;
|
|
$type = \PurplePixie\PhpDns\DNSTypes::NAME_A;
|
|
foreach ($dns_servers as $dns_server) {
|
|
try {
|
|
$query = new DNSQuery($dns_server);
|
|
$results = $query->query($host, $type);
|
|
if ($results === false || $query->hasError()) {
|
|
ray('Error: '.$query->getLasterror());
|
|
} else {
|
|
foreach ($results as $result) {
|
|
if ($result->getType() == $type) {
|
|
if (ipMatch($result->getData(), $cloudflare_ips->toArray(), $match)) {
|
|
$found_matching_ip = true;
|
|
break;
|
|
}
|
|
if ($result->getData() === $ip) {
|
|
$found_matching_ip = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (\Exception) {
|
|
}
|
|
}
|
|
|
|
return $found_matching_ip;
|
|
}
|
|
|
|
function ipMatch($ip, $cidrs, &$match = null)
|
|
{
|
|
foreach ((array) $cidrs as $cidr) {
|
|
[$subnet, $mask] = explode('/', $cidr);
|
|
if (((ip2long($ip) & ($mask = ~((1 << (32 - $mask)) - 1))) == (ip2long($subnet) & $mask))) {
|
|
$match = $cidr;
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function checkIPAgainstAllowlist($ip, $allowlist)
|
|
{
|
|
if (empty($allowlist)) {
|
|
return false;
|
|
}
|
|
|
|
foreach ((array) $allowlist as $allowed) {
|
|
$allowed = trim($allowed);
|
|
|
|
if (empty($allowed)) {
|
|
continue;
|
|
}
|
|
|
|
// Check if it's a CIDR notation
|
|
if (str_contains($allowed, '/')) {
|
|
[$subnet, $mask] = explode('/', $allowed);
|
|
|
|
// Special case: 0.0.0.0 with any subnet means allow all
|
|
if ($subnet === '0.0.0.0') {
|
|
return true;
|
|
}
|
|
|
|
$mask = (int) $mask;
|
|
|
|
// Validate mask
|
|
if ($mask < 0 || $mask > 32) {
|
|
continue;
|
|
}
|
|
|
|
// Calculate network addresses
|
|
$ip_long = ip2long($ip);
|
|
$subnet_long = ip2long($subnet);
|
|
|
|
if ($ip_long === false || $subnet_long === false) {
|
|
continue;
|
|
}
|
|
|
|
$mask_long = ~((1 << (32 - $mask)) - 1);
|
|
|
|
if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) {
|
|
return true;
|
|
}
|
|
} else {
|
|
// Special case: 0.0.0.0 means allow all
|
|
if ($allowed === '0.0.0.0') {
|
|
return true;
|
|
}
|
|
|
|
// Direct IP comparison
|
|
if ($ip === $allowed) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function get_public_ips()
|
|
{
|
|
try {
|
|
[$first, $second] = Process::concurrently(function (Pool $pool) {
|
|
$pool->path(__DIR__)->command('curl -4s https://ifconfig.io');
|
|
$pool->path(__DIR__)->command('curl -6s https://ifconfig.io');
|
|
});
|
|
$ipv4 = $first->output();
|
|
if ($ipv4) {
|
|
$ipv4 = trim($ipv4);
|
|
$validate_ipv4 = filter_var($ipv4, FILTER_VALIDATE_IP);
|
|
if ($validate_ipv4 == false) {
|
|
echo "Invalid ipv4: $ipv4\n";
|
|
|
|
return;
|
|
}
|
|
InstanceSettings::get()->update(['public_ipv4' => $ipv4]);
|
|
}
|
|
} catch (\Exception $e) {
|
|
echo "Error: {$e->getMessage()}\n";
|
|
}
|
|
try {
|
|
$ipv6 = $second->output();
|
|
if ($ipv6) {
|
|
$ipv6 = trim($ipv6);
|
|
$validate_ipv6 = filter_var($ipv6, FILTER_VALIDATE_IP);
|
|
if ($validate_ipv6 == false) {
|
|
echo "Invalid ipv6: $ipv6\n";
|
|
|
|
return;
|
|
}
|
|
InstanceSettings::get()->update(['public_ipv6' => $ipv6]);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
echo "Error: {$e->getMessage()}\n";
|
|
}
|
|
}
|
|
|
|
function isAnyDeploymentInprogress()
|
|
{
|
|
$runningJobs = ApplicationDeploymentQueue::where('horizon_job_worker', gethostname())->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)->get();
|
|
|
|
if ($runningJobs->isEmpty()) {
|
|
echo "No deployments in progress.\n";
|
|
exit(0);
|
|
}
|
|
|
|
$horizonJobIds = [];
|
|
$deploymentDetails = [];
|
|
|
|
foreach ($runningJobs as $runningJob) {
|
|
$horizonJobStatus = getJobStatus($runningJob->horizon_job_id);
|
|
if ($horizonJobStatus === 'unknown' || $horizonJobStatus === 'reserved') {
|
|
$horizonJobIds[] = $runningJob->horizon_job_id;
|
|
|
|
// Get application and team information
|
|
$application = Application::find($runningJob->application_id);
|
|
$teamMembers = [];
|
|
$deploymentUrl = '';
|
|
|
|
if ($application) {
|
|
// Get team members through the application's project
|
|
$team = $application->team();
|
|
if ($team) {
|
|
$teamMembers = $team->members()->pluck('email')->toArray();
|
|
}
|
|
|
|
// Construct the full deployment URL
|
|
if ($runningJob->deployment_url) {
|
|
$baseUrl = base_url();
|
|
$deploymentUrl = $baseUrl.$runningJob->deployment_url;
|
|
}
|
|
}
|
|
|
|
$deploymentDetails[] = [
|
|
'id' => $runningJob->id,
|
|
'application_name' => $runningJob->application_name ?? 'Unknown',
|
|
'server_name' => $runningJob->server_name ?? 'Unknown',
|
|
'deployment_url' => $deploymentUrl,
|
|
'team_members' => $teamMembers,
|
|
'created_at' => $runningJob->created_at->format('Y-m-d H:i:s'),
|
|
'horizon_job_id' => $runningJob->horizon_job_id,
|
|
];
|
|
}
|
|
}
|
|
|
|
if (count($horizonJobIds) === 0) {
|
|
echo "No active deployments in progress (all jobs completed or failed).\n";
|
|
exit(0);
|
|
}
|
|
|
|
// Display enhanced deployment information
|
|
echo "\n=== Running Deployments ===\n";
|
|
echo 'Total active deployments: '.count($horizonJobIds)."\n\n";
|
|
|
|
foreach ($deploymentDetails as $index => $deployment) {
|
|
echo 'Deployment #'.($index + 1).":\n";
|
|
echo ' Application: '.$deployment['application_name']."\n";
|
|
echo ' Server: '.$deployment['server_name']."\n";
|
|
echo ' Started: '.$deployment['created_at']."\n";
|
|
if ($deployment['deployment_url']) {
|
|
echo ' URL: '.$deployment['deployment_url']."\n";
|
|
}
|
|
if (! empty($deployment['team_members'])) {
|
|
echo ' Team members: '.implode(', ', $deployment['team_members'])."\n";
|
|
} else {
|
|
echo " Team members: No team members found\n";
|
|
}
|
|
echo ' Horizon Job ID: '.$deployment['horizon_job_id']."\n";
|
|
echo "\n";
|
|
}
|
|
|
|
exit(1);
|
|
}
|
|
|
|
function isBase64Encoded($strValue)
|
|
{
|
|
return base64_encode(base64_decode($strValue, true)) === $strValue;
|
|
}
|
|
function customApiValidator(Collection|array $item, array $rules)
|
|
{
|
|
if (is_array($item)) {
|
|
$item = collect($item);
|
|
}
|
|
|
|
return Validator::make($item->toArray(), $rules, [
|
|
'required' => 'This field is required.',
|
|
]);
|
|
}
|
|
function parseDockerComposeFile(Service|Application $resource, bool $isNew = false, int $pull_request_id = 0, ?int $preview_id = null)
|
|
{
|
|
if ($resource->getMorphClass() === \App\Models\Service::class) {
|
|
if ($resource->docker_compose_raw) {
|
|
try {
|
|
$yaml = Yaml::parse($resource->docker_compose_raw);
|
|
} catch (\Exception $e) {
|
|
throw new \RuntimeException($e->getMessage());
|
|
}
|
|
$allServices = get_service_templates();
|
|
$topLevelVolumes = collect(data_get($yaml, 'volumes', []));
|
|
$topLevelNetworks = collect(data_get($yaml, 'networks', []));
|
|
$topLevelConfigs = collect(data_get($yaml, 'configs', []));
|
|
$topLevelSecrets = collect(data_get($yaml, 'secrets', []));
|
|
$services = data_get($yaml, 'services');
|
|
|
|
$generatedServiceFQDNS = collect([]);
|
|
if (is_null($resource->destination)) {
|
|
$destination = $resource->server->destinations()->first();
|
|
if ($destination) {
|
|
$resource->destination()->associate($destination);
|
|
$resource->save();
|
|
}
|
|
}
|
|
$definedNetwork = collect([$resource->uuid]);
|
|
if ($topLevelVolumes->count() > 0) {
|
|
$tempTopLevelVolumes = collect([]);
|
|
foreach ($topLevelVolumes as $volumeName => $volume) {
|
|
if (is_null($volume)) {
|
|
continue;
|
|
}
|
|
$tempTopLevelVolumes->put($volumeName, $volume);
|
|
}
|
|
$topLevelVolumes = collect($tempTopLevelVolumes);
|
|
}
|
|
$services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices) {
|
|
// Workarounds for beta users.
|
|
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';
|
|
}
|
|
// End of workarounds for beta users.
|
|
$serviceVolumes = collect(data_get($service, 'volumes', []));
|
|
$servicePorts = collect(data_get($service, 'ports', []));
|
|
$serviceNetworks = collect(data_get($service, 'networks', []));
|
|
$serviceVariables = collect(data_get($service, 'environment', []));
|
|
$serviceLabels = collect(data_get($service, 'labels', []));
|
|
$networkMode = data_get($service, 'network_mode');
|
|
|
|
$hasValidNetworkMode =
|
|
$networkMode === 'host' ||
|
|
(is_string($networkMode) && (str_starts_with($networkMode, 'service:') || str_starts_with($networkMode, 'container:')));
|
|
|
|
if ($serviceLabels->count() > 0) {
|
|
$removedLabels = collect([]);
|
|
$serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) {
|
|
// Handle array values from YAML (e.g., "traefik.enable: true" becomes an array)
|
|
if (is_array($serviceLabel)) {
|
|
$removedLabels->put($serviceLabelName, $serviceLabel);
|
|
|
|
return false;
|
|
}
|
|
if (! str($serviceLabel)->contains('=')) {
|
|
$removedLabels->put($serviceLabelName, $serviceLabel);
|
|
|
|
return false;
|
|
}
|
|
|
|
return $serviceLabel;
|
|
});
|
|
foreach ($removedLabels as $removedLabelName => $removedLabel) {
|
|
// Convert array values to strings
|
|
if (is_array($removedLabel)) {
|
|
$removedLabel = (string) collect($removedLabel)->first();
|
|
}
|
|
$serviceLabels->push("$removedLabelName=$removedLabel");
|
|
}
|
|
}
|
|
$containerName = "$serviceName-{$resource->uuid}";
|
|
|
|
// Decide if the service is a database
|
|
$image = data_get_str($service, 'image');
|
|
|
|
// Check for manually migrated services first (respects user's conversion choice)
|
|
$migratedApp = ServiceApplication::where('name', $serviceName)
|
|
->where('service_id', $resource->id)
|
|
->where('is_migrated', true)
|
|
->first();
|
|
$migratedDb = ServiceDatabase::where('name', $serviceName)
|
|
->where('service_id', $resource->id)
|
|
->where('is_migrated', true)
|
|
->first();
|
|
|
|
if ($migratedApp || $migratedDb) {
|
|
// Use the migrated service type, ignoring image detection
|
|
$isDatabase = (bool) $migratedDb;
|
|
$savedService = $migratedApp ?: $migratedDb;
|
|
} else {
|
|
// Use image detection for non-migrated services
|
|
$isDatabase = isDatabaseImage($image, $service);
|
|
|
|
// Create new serviceApplication or serviceDatabase
|
|
if ($isDatabase) {
|
|
if ($isNew) {
|
|
$savedService = ServiceDatabase::create([
|
|
'name' => $serviceName,
|
|
'image' => $image,
|
|
'service_id' => $resource->id,
|
|
]);
|
|
} else {
|
|
$savedService = ServiceDatabase::where([
|
|
'name' => $serviceName,
|
|
'service_id' => $resource->id,
|
|
])->first();
|
|
if (is_null($savedService)) {
|
|
$savedService = ServiceDatabase::create([
|
|
'name' => $serviceName,
|
|
'image' => $image,
|
|
'service_id' => $resource->id,
|
|
]);
|
|
}
|
|
}
|
|
} else {
|
|
if ($isNew) {
|
|
$savedService = ServiceApplication::create([
|
|
'name' => $serviceName,
|
|
'image' => $image,
|
|
'service_id' => $resource->id,
|
|
]);
|
|
} else {
|
|
$savedService = ServiceApplication::where([
|
|
'name' => $serviceName,
|
|
'service_id' => $resource->id,
|
|
])->first();
|
|
if (is_null($savedService)) {
|
|
$savedService = ServiceApplication::create([
|
|
'name' => $serviceName,
|
|
'image' => $image,
|
|
'service_id' => $resource->id,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
data_set($service, 'is_database', $isDatabase);
|
|
|
|
// Check if image changed
|
|
if ($savedService->image !== $image) {
|
|
$savedService->image = $image;
|
|
$savedService->save();
|
|
}
|
|
// Collect/create/update networks
|
|
if ($serviceNetworks->count() > 0) {
|
|
foreach ($serviceNetworks as $networkName => $networkDetails) {
|
|
if ($networkName === 'default') {
|
|
continue;
|
|
}
|
|
// ignore alias
|
|
if ($networkDetails['aliases'] ?? false) {
|
|
continue;
|
|
}
|
|
$networkExists = $topLevelNetworks->contains(function ($value, $key) use ($networkName) {
|
|
return $value == $networkName || $key == $networkName;
|
|
});
|
|
if (! $networkExists) {
|
|
if (is_string($networkDetails) || is_int($networkDetails)) {
|
|
$topLevelNetworks->put($networkDetails, null);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect/create/update ports
|
|
$collectedPorts = collect([]);
|
|
if ($servicePorts->count() > 0) {
|
|
foreach ($servicePorts 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");
|
|
}
|
|
}
|
|
}
|
|
$savedService->ports = $collectedPorts->implode(',');
|
|
$savedService->save();
|
|
|
|
if (! $hasValidNetworkMode) {
|
|
// Add Coolify specific networks
|
|
$definedNetworkExists = $topLevelNetworks->contains(function ($value, $_) use ($definedNetwork) {
|
|
return $value == $definedNetwork;
|
|
});
|
|
if (! $definedNetworkExists) {
|
|
foreach ($definedNetwork as $network) {
|
|
$topLevelNetworks->put($network, [
|
|
'name' => $network,
|
|
'external' => true,
|
|
]);
|
|
}
|
|
}
|
|
$networks = collect();
|
|
foreach ($serviceNetworks as $key => $serviceNetwork) {
|
|
if (gettype($serviceNetwork) === 'string') {
|
|
// networks:
|
|
// - appwrite
|
|
$networks->put($serviceNetwork, null);
|
|
} elseif (gettype($serviceNetwork) === 'array') {
|
|
// networks:
|
|
// default:
|
|
// ipv4_address: 192.168.203.254
|
|
// $networks->put($serviceNetwork, null);
|
|
$networks->put($key, $serviceNetwork);
|
|
}
|
|
}
|
|
foreach ($definedNetwork as $key => $network) {
|
|
$networks->put($network, null);
|
|
}
|
|
data_set($service, 'networks', $networks->toArray());
|
|
}
|
|
|
|
// Collect/create/update volumes
|
|
if ($serviceVolumes->count() > 0) {
|
|
$serviceVolumes = $serviceVolumes->map(function ($volume) use ($savedService, $topLevelVolumes) {
|
|
$type = null;
|
|
$source = null;
|
|
$target = null;
|
|
$content = null;
|
|
$isDirectory = false;
|
|
if (is_string($volume)) {
|
|
$source = str($volume)->before(':');
|
|
$target = str($volume)->after(':')->beforeLast(':');
|
|
if ($source->startsWith('./') || $source->startsWith('/') || $source->startsWith('~')) {
|
|
$type = str('bind');
|
|
// 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);
|
|
$foundConfig = $savedService->fileStorages()->whereMountPath($target)->first();
|
|
if ($foundConfig) {
|
|
$contentNotNull = data_get($foundConfig, 'content');
|
|
if ($contentNotNull) {
|
|
$content = $contentNotNull;
|
|
}
|
|
$isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
|
|
}
|
|
if (is_null($isDirectory) && is_null($content)) {
|
|
// if isDirectory is not set & content is also not set, we assume it is a directory
|
|
$isDirectory = true;
|
|
}
|
|
}
|
|
if ($type?->value() === 'bind') {
|
|
if ($source->value() === '/var/run/docker.sock') {
|
|
return $volume;
|
|
}
|
|
if ($source->value() === '/tmp' || $source->value() === '/tmp/') {
|
|
return $volume;
|
|
}
|
|
|
|
LocalFileVolume::updateOrCreate(
|
|
[
|
|
'mount_path' => $target,
|
|
'resource_id' => $savedService->id,
|
|
'resource_type' => get_class($savedService),
|
|
],
|
|
[
|
|
'fs_path' => $source,
|
|
'mount_path' => $target,
|
|
'content' => $content,
|
|
'is_directory' => $isDirectory,
|
|
'resource_id' => $savedService->id,
|
|
'resource_type' => get_class($savedService),
|
|
]
|
|
);
|
|
} elseif ($type->value() === 'volume') {
|
|
if ($topLevelVolumes->has($source->value())) {
|
|
$v = $topLevelVolumes->get($source->value());
|
|
if (data_get($v, 'driver_opts.type') === 'cifs') {
|
|
return $volume;
|
|
}
|
|
}
|
|
$slugWithoutUuid = Str::slug($source, '-');
|
|
$name = "{$savedService->service->uuid}_{$slugWithoutUuid}";
|
|
if (is_string($volume)) {
|
|
$source = str($volume)->before(':');
|
|
$target = str($volume)->after(':')->beforeLast(':');
|
|
$source = $name;
|
|
$volume = "$source:$target";
|
|
} elseif (is_array($volume)) {
|
|
data_set($volume, 'source', $name);
|
|
}
|
|
$topLevelVolumes->put($name, [
|
|
'name' => $name,
|
|
]);
|
|
LocalPersistentVolume::updateOrCreate(
|
|
[
|
|
'mount_path' => $target,
|
|
'resource_id' => $savedService->id,
|
|
'resource_type' => get_class($savedService),
|
|
],
|
|
[
|
|
'name' => $name,
|
|
'mount_path' => $target,
|
|
'resource_id' => $savedService->id,
|
|
'resource_type' => get_class($savedService),
|
|
]
|
|
);
|
|
}
|
|
dispatch(new ServerFilesFromServerJob($savedService));
|
|
|
|
return $volume;
|
|
});
|
|
data_set($service, 'volumes', $serviceVolumes->toArray());
|
|
}
|
|
|
|
// convert - SESSION_SECRET: 123 to - SESSION_SECRET=123
|
|
$convertedServiceVariables = collect([]);
|
|
foreach ($serviceVariables as $variableName => $variable) {
|
|
if (is_numeric($variableName)) {
|
|
if (is_array($variable)) {
|
|
$key = str(collect($variable)->keys()->first());
|
|
$value = str(collect($variable)->values()->first());
|
|
$variable = "$key=$value";
|
|
$convertedServiceVariables->put($variableName, $variable);
|
|
} elseif (is_string($variable)) {
|
|
$convertedServiceVariables->put($variableName, $variable);
|
|
}
|
|
} elseif (is_string($variableName)) {
|
|
$convertedServiceVariables->put($variableName, $variable);
|
|
}
|
|
}
|
|
$serviceVariables = $convertedServiceVariables;
|
|
// Get variables from the service
|
|
foreach ($serviceVariables as $variableName => $variable) {
|
|
if (is_numeric($variableName)) {
|
|
if (is_array($variable)) {
|
|
// - SESSION_SECRET: 123
|
|
// - SESSION_SECRET:
|
|
$key = str(collect($variable)->keys()->first());
|
|
$value = str(collect($variable)->values()->first());
|
|
} else {
|
|
$variable = str($variable);
|
|
if ($variable->contains('=')) {
|
|
// - SESSION_SECRET=123
|
|
// - SESSION_SECRET=
|
|
$key = $variable->before('=');
|
|
$value = $variable->after('=');
|
|
} else {
|
|
// - SESSION_SECRET
|
|
$key = $variable;
|
|
$value = null;
|
|
}
|
|
}
|
|
} else {
|
|
// SESSION_SECRET: 123
|
|
// SESSION_SECRET:
|
|
$key = str($variableName);
|
|
$value = str($variable);
|
|
}
|
|
if ($key->startsWith('SERVICE_FQDN')) {
|
|
if ($isNew || $savedService->fqdn === null) {
|
|
$name = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower();
|
|
$fqdn = generateFqdn($resource->server, "{$name->value()}-{$resource->uuid}");
|
|
if (substr_count($key->value(), '_') === 3) {
|
|
// SERVICE_FQDN_UMAMI_1000
|
|
$port = $key->afterLast('_');
|
|
} else {
|
|
$last = $key->afterLast('_');
|
|
if (is_numeric($last->value())) {
|
|
// SERVICE_FQDN_3001
|
|
$port = $last;
|
|
} else {
|
|
// SERVICE_FQDN_UMAMI
|
|
$port = null;
|
|
}
|
|
}
|
|
if ($port) {
|
|
$fqdn = "$fqdn:$port";
|
|
}
|
|
if (substr_count($key->value(), '_') >= 2) {
|
|
if ($value) {
|
|
$path = $value->value();
|
|
} else {
|
|
$path = null;
|
|
}
|
|
if ($generatedServiceFQDNS->count() > 0) {
|
|
$alreadyGenerated = $generatedServiceFQDNS->has($key->value());
|
|
if ($alreadyGenerated) {
|
|
$fqdn = $generatedServiceFQDNS->get($key->value());
|
|
} else {
|
|
$generatedServiceFQDNS->put($key->value(), $fqdn);
|
|
}
|
|
} else {
|
|
$generatedServiceFQDNS->put($key->value(), $fqdn);
|
|
}
|
|
$fqdn = "$fqdn$path";
|
|
}
|
|
|
|
if (! $isDatabase) {
|
|
if ($savedService->fqdn) {
|
|
data_set($savedService, 'fqdn', $savedService->fqdn.','.$fqdn);
|
|
} else {
|
|
data_set($savedService, 'fqdn', $fqdn);
|
|
}
|
|
$savedService->save();
|
|
}
|
|
EnvironmentVariable::create([
|
|
'key' => $key,
|
|
'value' => $fqdn,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
'is_preview' => false,
|
|
]);
|
|
}
|
|
// Caddy needs exact port in some cases.
|
|
if ($predefinedPort && ! $key->endsWith("_{$predefinedPort}")) {
|
|
$fqdns_exploded = str($savedService->fqdn)->explode(',');
|
|
if ($fqdns_exploded->count() > 1) {
|
|
continue;
|
|
}
|
|
$env = EnvironmentVariable::where([
|
|
'key' => $key,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
])->first();
|
|
if ($env) {
|
|
$env_url = Url::fromString($savedService->fqdn);
|
|
$env_port = $env_url->getPort();
|
|
if ($env_port !== $predefinedPort) {
|
|
$env_url = $env_url->withPort($predefinedPort);
|
|
$savedService->fqdn = $env_url->__toString();
|
|
$savedService->save();
|
|
}
|
|
}
|
|
}
|
|
|
|
// data_forget($service, "environment.$variableName");
|
|
// $yaml = data_forget($yaml, "services.$serviceName.environment.$variableName");
|
|
// if (count(data_get($yaml, 'services.' . $serviceName . '.environment')) === 0) {
|
|
// $yaml = data_forget($yaml, "services.$serviceName.environment");
|
|
// }
|
|
continue;
|
|
}
|
|
if ($value?->startsWith('$')) {
|
|
$foundEnv = EnvironmentVariable::where([
|
|
'key' => $key,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
])->first();
|
|
$value = replaceVariables($value);
|
|
$key = $value;
|
|
if ($value->startsWith('SERVICE_')) {
|
|
$foundEnv = EnvironmentVariable::where([
|
|
'key' => $key,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
])->first();
|
|
['command' => $command, 'forService' => $forService, 'generatedValue' => $generatedValue, 'port' => $port] = parseEnvVariable($value);
|
|
if (! is_null($command)) {
|
|
if ($command?->value() === 'FQDN' || $command?->value() === 'URL') {
|
|
if (Str::lower($forService) === $serviceName) {
|
|
$fqdn = generateFqdn($resource->server, $containerName);
|
|
} else {
|
|
$fqdn = generateFqdn($resource->server, Str::lower($forService).'-'.$resource->uuid);
|
|
}
|
|
if ($port) {
|
|
$fqdn = "$fqdn:$port";
|
|
}
|
|
if ($foundEnv) {
|
|
$fqdn = data_get($foundEnv, 'value');
|
|
// if ($savedService->fqdn) {
|
|
// $savedServiceFqdn = Url::fromString($savedService->fqdn);
|
|
// $parsedFqdn = Url::fromString($fqdn);
|
|
// $savedServicePath = $savedServiceFqdn->getPath();
|
|
// $parsedFqdnPath = $parsedFqdn->getPath();
|
|
// if ($savedServicePath != $parsedFqdnPath) {
|
|
// $fqdn = $parsedFqdn->withPath($savedServicePath)->__toString();
|
|
// $foundEnv->value = $fqdn;
|
|
// $foundEnv->save();
|
|
// }
|
|
// }
|
|
} else {
|
|
if ($command->value() === 'URL') {
|
|
$fqdn = str($fqdn)->after('://')->value();
|
|
}
|
|
EnvironmentVariable::create([
|
|
'key' => $key,
|
|
'value' => $fqdn,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
'is_preview' => false,
|
|
]);
|
|
}
|
|
if (! $isDatabase) {
|
|
if ($command->value() === 'FQDN' && is_null($savedService->fqdn) && ! $foundEnv) {
|
|
$savedService->fqdn = $fqdn;
|
|
$savedService->save();
|
|
}
|
|
// Caddy needs exact port in some cases.
|
|
if ($predefinedPort && ! $key->endsWith("_{$predefinedPort}") && $command?->value() === 'FQDN' && $resource->server->proxyType() === 'CADDY') {
|
|
$fqdns_exploded = str($savedService->fqdn)->explode(',');
|
|
if ($fqdns_exploded->count() > 1) {
|
|
continue;
|
|
}
|
|
$env = EnvironmentVariable::where([
|
|
'key' => $key,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
])->first();
|
|
if ($env) {
|
|
$env_url = Url::fromString($env->value);
|
|
$env_port = $env_url->getPort();
|
|
if ($env_port !== $predefinedPort) {
|
|
$env_url = $env_url->withPort($predefinedPort);
|
|
$savedService->fqdn = $env_url->__toString();
|
|
$savedService->save();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
$generatedValue = generateEnvValue($command, $resource);
|
|
if (! $foundEnv) {
|
|
EnvironmentVariable::create([
|
|
'key' => $key,
|
|
'value' => $generatedValue,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
'is_preview' => false,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if ($value->contains(':-')) {
|
|
$key = $value->before(':');
|
|
$defaultValue = $value->after(':-');
|
|
} elseif ($value->contains('-')) {
|
|
$key = $value->before('-');
|
|
$defaultValue = $value->after('-');
|
|
} elseif ($value->contains(':?')) {
|
|
$key = $value->before(':');
|
|
$defaultValue = $value->after(':?');
|
|
} elseif ($value->contains('?')) {
|
|
$key = $value->before('?');
|
|
$defaultValue = $value->after('?');
|
|
} else {
|
|
$key = $value;
|
|
$defaultValue = null;
|
|
}
|
|
$foundEnv = EnvironmentVariable::where([
|
|
'key' => $key,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
])->first();
|
|
if ($foundEnv) {
|
|
$defaultValue = data_get($foundEnv, 'value');
|
|
}
|
|
EnvironmentVariable::updateOrCreate([
|
|
'key' => $key,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
], [
|
|
'value' => $defaultValue,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
'is_preview' => false,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
// Add labels to the service
|
|
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->name,
|
|
environment: $resource->environment->name,
|
|
);
|
|
$serviceLabels = $serviceLabels->merge($defaultLabels);
|
|
if (! $isDatabase && $fqdns->count() > 0) {
|
|
if ($fqdns) {
|
|
$shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels;
|
|
if ($shouldGenerateLabelsExactly) {
|
|
switch ($resource->server->proxyType()) {
|
|
case ProxyTypes::TRAEFIK->value:
|
|
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
|
|
uuid: $resource->uuid,
|
|
domains: $fqdns,
|
|
is_force_https_enabled: true,
|
|
serviceLabels: $serviceLabels,
|
|
is_gzip_enabled: $savedService->isGzipEnabled(),
|
|
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
|
service_name: $serviceName,
|
|
image: data_get($service, 'image')
|
|
));
|
|
break;
|
|
case ProxyTypes::CADDY->value:
|
|
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
|
network: $resource->destination->network,
|
|
uuid: $resource->uuid,
|
|
domains: $fqdns,
|
|
is_force_https_enabled: true,
|
|
serviceLabels: $serviceLabels,
|
|
is_gzip_enabled: $savedService->isGzipEnabled(),
|
|
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
|
service_name: $serviceName,
|
|
image: data_get($service, 'image')
|
|
));
|
|
break;
|
|
}
|
|
} else {
|
|
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
|
|
uuid: $resource->uuid,
|
|
domains: $fqdns,
|
|
is_force_https_enabled: true,
|
|
serviceLabels: $serviceLabels,
|
|
is_gzip_enabled: $savedService->isGzipEnabled(),
|
|
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
|
service_name: $serviceName,
|
|
image: data_get($service, 'image')
|
|
));
|
|
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
|
network: $resource->destination->network,
|
|
uuid: $resource->uuid,
|
|
domains: $fqdns,
|
|
is_force_https_enabled: true,
|
|
serviceLabels: $serviceLabels,
|
|
is_gzip_enabled: $savedService->isGzipEnabled(),
|
|
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
|
service_name: $serviceName,
|
|
image: data_get($service, 'image')
|
|
));
|
|
}
|
|
}
|
|
}
|
|
if ($resource->server->isLogDrainEnabled() && $savedService->isLogDrainEnabled()) {
|
|
data_set($service, 'logging', generate_fluentd_configuration());
|
|
}
|
|
if ($serviceLabels->count() > 0) {
|
|
if ($resource->is_container_label_escape_enabled) {
|
|
$serviceLabels = $serviceLabels->map(function ($value, $key) {
|
|
return escapeDollarSign($value);
|
|
});
|
|
}
|
|
}
|
|
data_set($service, 'labels', $serviceLabels->toArray());
|
|
data_forget($service, 'is_database');
|
|
if (! data_get($service, 'restart')) {
|
|
data_set($service, 'restart', RESTART_MODE);
|
|
}
|
|
if (data_get($service, 'restart') === 'no' || data_get($service, 'exclude_from_hc')) {
|
|
$savedService->update(['exclude_from_status' => true]);
|
|
}
|
|
data_set($service, 'container_name', $containerName);
|
|
data_forget($service, 'volumes.*.content');
|
|
data_forget($service, 'volumes.*.isDirectory');
|
|
data_forget($service, 'volumes.*.is_directory');
|
|
data_forget($service, 'exclude_from_hc');
|
|
data_set($service, 'environment', $serviceVariables->toArray());
|
|
updateCompose($savedService);
|
|
|
|
return $service;
|
|
});
|
|
|
|
$envs_from_coolify = $resource->environment_variables()->get();
|
|
$services = collect($services)->map(function ($service, $serviceName) use ($resource, $envs_from_coolify) {
|
|
$serviceVariables = collect(data_get($service, 'environment', []));
|
|
$parsedServiceVariables = collect([]);
|
|
foreach ($serviceVariables as $key => $value) {
|
|
if (is_numeric($key)) {
|
|
$value = str($value);
|
|
if ($value->contains('=')) {
|
|
$key = $value->before('=')->value();
|
|
$value = $value->after('=')->value();
|
|
} else {
|
|
$key = $value->value();
|
|
$value = null;
|
|
}
|
|
$parsedServiceVariables->put($key, $value);
|
|
} else {
|
|
$parsedServiceVariables->put($key, $value);
|
|
}
|
|
}
|
|
$parsedServiceVariables->put('COOLIFY_RESOURCE_UUID', "{$resource->uuid}");
|
|
$parsedServiceVariables->put('COOLIFY_CONTAINER_NAME', "$serviceName-{$resource->uuid}");
|
|
|
|
// TODO: move this in a shared function
|
|
if (! $parsedServiceVariables->has('COOLIFY_APP_NAME')) {
|
|
$parsedServiceVariables->put('COOLIFY_APP_NAME', "\"{$resource->name}\"");
|
|
}
|
|
if (! $parsedServiceVariables->has('COOLIFY_SERVER_IP')) {
|
|
$parsedServiceVariables->put('COOLIFY_SERVER_IP', "\"{$resource->destination->server->ip}\"");
|
|
}
|
|
if (! $parsedServiceVariables->has('COOLIFY_ENVIRONMENT_NAME')) {
|
|
$parsedServiceVariables->put('COOLIFY_ENVIRONMENT_NAME', "\"{$resource->environment->name}\"");
|
|
}
|
|
if (! $parsedServiceVariables->has('COOLIFY_PROJECT_NAME')) {
|
|
$parsedServiceVariables->put('COOLIFY_PROJECT_NAME', "\"{$resource->project()->name}\"");
|
|
}
|
|
|
|
$parsedServiceVariables = $parsedServiceVariables->map(function ($value, $key) use ($envs_from_coolify) {
|
|
if (! str($value)->startsWith('$')) {
|
|
$found_env = $envs_from_coolify->where('key', $key)->first();
|
|
if ($found_env) {
|
|
return $found_env->value;
|
|
}
|
|
}
|
|
|
|
return $value;
|
|
});
|
|
|
|
data_set($service, 'environment', $parsedServiceVariables->toArray());
|
|
|
|
return $service;
|
|
});
|
|
$finalServices = [
|
|
'services' => $services->toArray(),
|
|
'volumes' => $topLevelVolumes->toArray(),
|
|
'networks' => $topLevelNetworks->toArray(),
|
|
'configs' => $topLevelConfigs->toArray(),
|
|
'secrets' => $topLevelSecrets->toArray(),
|
|
];
|
|
$yaml = data_forget($yaml, 'services.*.volumes.*.content');
|
|
$resource->docker_compose_raw = Yaml::dump($yaml, 10, 2);
|
|
$resource->docker_compose = Yaml::dump($finalServices, 10, 2);
|
|
|
|
$resource->save();
|
|
$resource->saveComposeConfigs();
|
|
|
|
return collect($finalServices);
|
|
} else {
|
|
return collect([]);
|
|
}
|
|
} elseif ($resource->getMorphClass() === \App\Models\Application::class) {
|
|
try {
|
|
$yaml = Yaml::parse($resource->docker_compose_raw);
|
|
} catch (\Exception) {
|
|
return;
|
|
}
|
|
$server = $resource->destination->server;
|
|
$topLevelVolumes = collect(data_get($yaml, 'volumes', []));
|
|
if ($pull_request_id !== 0) {
|
|
$topLevelVolumes = collect([]);
|
|
}
|
|
|
|
if ($topLevelVolumes->count() > 0) {
|
|
$tempTopLevelVolumes = collect([]);
|
|
foreach ($topLevelVolumes as $volumeName => $volume) {
|
|
if (is_null($volume)) {
|
|
continue;
|
|
}
|
|
$tempTopLevelVolumes->put($volumeName, $volume);
|
|
}
|
|
$topLevelVolumes = collect($tempTopLevelVolumes);
|
|
}
|
|
|
|
$topLevelNetworks = collect(data_get($yaml, 'networks', []));
|
|
$topLevelConfigs = collect(data_get($yaml, 'configs', []));
|
|
$topLevelSecrets = collect(data_get($yaml, 'secrets', []));
|
|
$services = data_get($yaml, 'services');
|
|
|
|
$generatedServiceFQDNS = collect([]);
|
|
if (is_null($resource->destination)) {
|
|
$destination = $server->destinations()->first();
|
|
if ($destination) {
|
|
$resource->destination()->associate($destination);
|
|
$resource->save();
|
|
}
|
|
}
|
|
$definedNetwork = collect([$resource->uuid]);
|
|
if ($pull_request_id !== 0) {
|
|
$definedNetwork = collect(["{$resource->uuid}-$pull_request_id"]);
|
|
}
|
|
$services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $server, $pull_request_id, $preview_id) {
|
|
$serviceVolumes = collect(data_get($service, 'volumes', []));
|
|
$servicePorts = collect(data_get($service, 'ports', []));
|
|
$serviceNetworks = collect(data_get($service, 'networks', []));
|
|
$serviceVariables = collect(data_get($service, 'environment', []));
|
|
$serviceDependencies = collect(data_get($service, 'depends_on', []));
|
|
$serviceLabels = collect(data_get($service, 'labels', []));
|
|
$serviceBuildVariables = collect(data_get($service, 'build.args', []));
|
|
$serviceVariables = $serviceVariables->merge($serviceBuildVariables);
|
|
if ($serviceLabels->count() > 0) {
|
|
$removedLabels = collect([]);
|
|
$serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) {
|
|
// Handle array values from YAML (e.g., "traefik.enable: true" becomes an array)
|
|
if (is_array($serviceLabel)) {
|
|
$removedLabels->put($serviceLabelName, $serviceLabel);
|
|
|
|
return false;
|
|
}
|
|
if (! str($serviceLabel)->contains('=')) {
|
|
$removedLabels->put($serviceLabelName, $serviceLabel);
|
|
|
|
return false;
|
|
}
|
|
|
|
return $serviceLabel;
|
|
});
|
|
foreach ($removedLabels as $removedLabelName => $removedLabel) {
|
|
// Convert array values to strings
|
|
if (is_array($removedLabel)) {
|
|
$removedLabel = (string) collect($removedLabel)->first();
|
|
}
|
|
$serviceLabels->push("$removedLabelName=$removedLabel");
|
|
}
|
|
}
|
|
|
|
$baseName = generateApplicationContainerName($resource, $pull_request_id);
|
|
$containerName = "$serviceName-$baseName";
|
|
if ($resource->compose_parsing_version === '1') {
|
|
if (count($serviceVolumes) > 0) {
|
|
$serviceVolumes = $serviceVolumes->map(function ($volume) use ($resource, $topLevelVolumes, $pull_request_id) {
|
|
if (is_string($volume)) {
|
|
$volume = str($volume);
|
|
if ($volume->contains(':') && ! $volume->startsWith('/')) {
|
|
$name = $volume->before(':');
|
|
$mount = $volume->after(':');
|
|
if ($name->startsWith('.') || $name->startsWith('~')) {
|
|
$dir = base_configuration_dir().'/applications/'.$resource->uuid;
|
|
if ($name->startsWith('.')) {
|
|
$name = $name->replaceFirst('.', $dir);
|
|
}
|
|
if ($name->startsWith('~')) {
|
|
$name = $name->replaceFirst('~', $dir);
|
|
}
|
|
if ($pull_request_id !== 0) {
|
|
$name = addPreviewDeploymentSuffix($name, $pull_request_id);
|
|
}
|
|
$volume = str("$name:$mount");
|
|
} else {
|
|
if ($pull_request_id !== 0) {
|
|
$name = addPreviewDeploymentSuffix($name, $pull_request_id);
|
|
$volume = str("$name:$mount");
|
|
if ($topLevelVolumes->has($name)) {
|
|
$v = $topLevelVolumes->get($name);
|
|
if (data_get($v, 'driver_opts.type') === 'cifs') {
|
|
// Do nothing
|
|
} else {
|
|
if (is_null(data_get($v, 'name'))) {
|
|
data_set($v, 'name', $name);
|
|
data_set($topLevelVolumes, $name, $v);
|
|
}
|
|
}
|
|
} else {
|
|
$topLevelVolumes->put($name, [
|
|
'name' => $name,
|
|
]);
|
|
}
|
|
} else {
|
|
if ($topLevelVolumes->has($name->value())) {
|
|
$v = $topLevelVolumes->get($name->value());
|
|
if (data_get($v, 'driver_opts.type') === 'cifs') {
|
|
// Do nothing
|
|
} else {
|
|
if (is_null(data_get($v, 'name'))) {
|
|
data_set($topLevelVolumes, $name->value(), $v);
|
|
}
|
|
}
|
|
} else {
|
|
$topLevelVolumes->put($name->value(), [
|
|
'name' => $name->value(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if ($volume->startsWith('/')) {
|
|
$name = $volume->before(':');
|
|
$mount = $volume->after(':');
|
|
if ($pull_request_id !== 0) {
|
|
$name = addPreviewDeploymentSuffix($name, $pull_request_id);
|
|
}
|
|
$volume = str("$name:$mount");
|
|
}
|
|
}
|
|
} elseif (is_array($volume)) {
|
|
$source = data_get($volume, 'source');
|
|
$target = data_get($volume, 'target');
|
|
$read_only = data_get($volume, 'read_only');
|
|
if ($source && $target) {
|
|
if ((str($source)->startsWith('.') || str($source)->startsWith('~'))) {
|
|
$dir = base_configuration_dir().'/applications/'.$resource->uuid;
|
|
if (str($source, '.')) {
|
|
$source = str($source)->replaceFirst('.', $dir);
|
|
}
|
|
if (str($source, '~')) {
|
|
$source = str($source)->replaceFirst('~', $dir);
|
|
}
|
|
if ($pull_request_id !== 0) {
|
|
$source = addPreviewDeploymentSuffix($source, $pull_request_id);
|
|
}
|
|
if ($read_only) {
|
|
data_set($volume, 'source', $source.':'.$target.':ro');
|
|
} else {
|
|
data_set($volume, 'source', $source.':'.$target);
|
|
}
|
|
} else {
|
|
if ($pull_request_id !== 0) {
|
|
$source = addPreviewDeploymentSuffix($source, $pull_request_id);
|
|
}
|
|
if ($read_only) {
|
|
data_set($volume, 'source', $source.':'.$target.':ro');
|
|
} else {
|
|
data_set($volume, 'source', $source.':'.$target);
|
|
}
|
|
if (! str($source)->startsWith('/')) {
|
|
if ($topLevelVolumes->has($source)) {
|
|
$v = $topLevelVolumes->get($source);
|
|
if (data_get($v, 'driver_opts.type') === 'cifs') {
|
|
// Do nothing
|
|
} else {
|
|
if (is_null(data_get($v, 'name'))) {
|
|
data_set($v, 'name', $source);
|
|
data_set($topLevelVolumes, $source, $v);
|
|
}
|
|
}
|
|
} else {
|
|
$topLevelVolumes->put($source, [
|
|
'name' => $source,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (is_array($volume)) {
|
|
return data_get($volume, 'source');
|
|
}
|
|
|
|
return $volume->value();
|
|
});
|
|
data_set($service, 'volumes', $serviceVolumes->toArray());
|
|
}
|
|
} elseif ($resource->compose_parsing_version === '2') {
|
|
if (count($serviceVolumes) > 0) {
|
|
$serviceVolumes = $serviceVolumes->map(function ($volume) use ($resource, $topLevelVolumes, $pull_request_id) {
|
|
if (is_string($volume)) {
|
|
$volume = str($volume);
|
|
if ($volume->contains(':') && ! $volume->startsWith('/')) {
|
|
$name = $volume->before(':');
|
|
$mount = $volume->after(':');
|
|
if ($name->startsWith('.') || $name->startsWith('~')) {
|
|
$dir = base_configuration_dir().'/applications/'.$resource->uuid;
|
|
if ($name->startsWith('.')) {
|
|
$name = $name->replaceFirst('.', $dir);
|
|
}
|
|
if ($name->startsWith('~')) {
|
|
$name = $name->replaceFirst('~', $dir);
|
|
}
|
|
if ($pull_request_id !== 0) {
|
|
$name = addPreviewDeploymentSuffix($name, $pull_request_id);
|
|
}
|
|
$volume = str("$name:$mount");
|
|
} else {
|
|
if ($pull_request_id !== 0) {
|
|
$uuid = $resource->uuid;
|
|
$name = $uuid.'-'.addPreviewDeploymentSuffix($name, $pull_request_id);
|
|
$volume = str("$name:$mount");
|
|
if ($topLevelVolumes->has($name)) {
|
|
$v = $topLevelVolumes->get($name);
|
|
if (data_get($v, 'driver_opts.type') === 'cifs') {
|
|
// Do nothing
|
|
} else {
|
|
if (is_null(data_get($v, 'name'))) {
|
|
data_set($v, 'name', $name);
|
|
data_set($topLevelVolumes, $name, $v);
|
|
}
|
|
}
|
|
} else {
|
|
$topLevelVolumes->put($name, [
|
|
'name' => $name,
|
|
]);
|
|
}
|
|
} else {
|
|
$uuid = $resource->uuid;
|
|
$name = str($uuid."-$name");
|
|
$volume = str("$name:$mount");
|
|
if ($topLevelVolumes->has($name->value())) {
|
|
$v = $topLevelVolumes->get($name->value());
|
|
if (data_get($v, 'driver_opts.type') === 'cifs') {
|
|
// Do nothing
|
|
} else {
|
|
if (is_null(data_get($v, 'name'))) {
|
|
data_set($topLevelVolumes, $name->value(), $v);
|
|
}
|
|
}
|
|
} else {
|
|
$topLevelVolumes->put($name->value(), [
|
|
'name' => $name->value(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if ($volume->startsWith('/')) {
|
|
$name = $volume->before(':');
|
|
$mount = $volume->after(':');
|
|
if ($pull_request_id !== 0) {
|
|
$name = addPreviewDeploymentSuffix($name, $pull_request_id);
|
|
}
|
|
$volume = str("$name:$mount");
|
|
}
|
|
}
|
|
} elseif (is_array($volume)) {
|
|
$source = data_get($volume, 'source');
|
|
$target = data_get($volume, 'target');
|
|
$read_only = data_get($volume, 'read_only');
|
|
if ($source && $target) {
|
|
$uuid = $resource->uuid;
|
|
if ((str($source)->startsWith('.') || str($source)->startsWith('~') || str($source)->startsWith('/'))) {
|
|
$dir = base_configuration_dir().'/applications/'.$resource->uuid;
|
|
if (str($source, '.')) {
|
|
$source = str($source)->replaceFirst('.', $dir);
|
|
}
|
|
if (str($source, '~')) {
|
|
$source = str($source)->replaceFirst('~', $dir);
|
|
}
|
|
if ($read_only) {
|
|
data_set($volume, 'source', $source.':'.$target.':ro');
|
|
} else {
|
|
data_set($volume, 'source', $source.':'.$target);
|
|
}
|
|
} else {
|
|
if ($pull_request_id === 0) {
|
|
$source = $uuid."-$source";
|
|
} else {
|
|
$source = $uuid.'-'.addPreviewDeploymentSuffix($source, $pull_request_id);
|
|
}
|
|
if ($read_only) {
|
|
data_set($volume, 'source', $source.':'.$target.':ro');
|
|
} else {
|
|
data_set($volume, 'source', $source.':'.$target);
|
|
}
|
|
if (! str($source)->startsWith('/')) {
|
|
if ($topLevelVolumes->has($source)) {
|
|
$v = $topLevelVolumes->get($source);
|
|
if (data_get($v, 'driver_opts.type') === 'cifs') {
|
|
// Do nothing
|
|
} else {
|
|
if (is_null(data_get($v, 'name'))) {
|
|
data_set($v, 'name', $source);
|
|
data_set($topLevelVolumes, $source, $v);
|
|
}
|
|
}
|
|
} else {
|
|
$topLevelVolumes->put($source, [
|
|
'name' => $source,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (is_array($volume)) {
|
|
return data_get($volume, 'source');
|
|
}
|
|
dispatch(new ServerFilesFromServerJob($resource));
|
|
|
|
return $volume->value();
|
|
});
|
|
data_set($service, 'volumes', $serviceVolumes->toArray());
|
|
}
|
|
}
|
|
|
|
if ($pull_request_id !== 0 && count($serviceDependencies) > 0) {
|
|
$serviceDependencies = $serviceDependencies->map(function ($dependency) use ($pull_request_id) {
|
|
return addPreviewDeploymentSuffix($dependency, $pull_request_id);
|
|
});
|
|
data_set($service, 'depends_on', $serviceDependencies->toArray());
|
|
}
|
|
|
|
// Decide if the service is a database
|
|
$image = data_get_str($service, 'image');
|
|
$isDatabase = isDatabaseImage($image, $service);
|
|
data_set($service, 'is_database', $isDatabase);
|
|
|
|
// Collect/create/update networks
|
|
if ($serviceNetworks->count() > 0) {
|
|
foreach ($serviceNetworks as $networkName => $networkDetails) {
|
|
if ($networkName === 'default') {
|
|
continue;
|
|
}
|
|
// ignore alias
|
|
if ($networkDetails['aliases'] ?? false) {
|
|
continue;
|
|
}
|
|
$networkExists = $topLevelNetworks->contains(function ($value, $key) use ($networkName) {
|
|
return $value == $networkName || $key == $networkName;
|
|
});
|
|
if (! $networkExists) {
|
|
if (is_string($networkDetails) || is_int($networkDetails)) {
|
|
$topLevelNetworks->put($networkDetails, null);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Collect/create/update ports
|
|
$collectedPorts = collect([]);
|
|
if ($servicePorts->count() > 0) {
|
|
foreach ($servicePorts 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");
|
|
}
|
|
}
|
|
}
|
|
$definedNetworkExists = $topLevelNetworks->contains(function ($value, $_) use ($definedNetwork) {
|
|
return $value == $definedNetwork;
|
|
});
|
|
if (! $definedNetworkExists) {
|
|
foreach ($definedNetwork as $network) {
|
|
if ($pull_request_id !== 0) {
|
|
$topLevelNetworks->put($network, [
|
|
'name' => $network,
|
|
'external' => true,
|
|
]);
|
|
} else {
|
|
$topLevelNetworks->put($network, [
|
|
'name' => $network,
|
|
'external' => true,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
$networks = collect();
|
|
foreach ($serviceNetworks as $key => $serviceNetwork) {
|
|
if (gettype($serviceNetwork) === 'string') {
|
|
// networks:
|
|
// - appwrite
|
|
$networks->put($serviceNetwork, null);
|
|
} elseif (gettype($serviceNetwork) === 'array') {
|
|
// networks:
|
|
// default:
|
|
// ipv4_address: 192.168.203.254
|
|
// $networks->put($serviceNetwork, null);
|
|
$networks->put($key, $serviceNetwork);
|
|
}
|
|
}
|
|
foreach ($definedNetwork as $key => $network) {
|
|
$networks->put($network, null);
|
|
}
|
|
if (data_get($resource, 'settings.connect_to_docker_network')) {
|
|
$network = $resource->destination->network;
|
|
$networks->put($network, null);
|
|
$topLevelNetworks->put($network, [
|
|
'name' => $network,
|
|
'external' => true,
|
|
]);
|
|
}
|
|
data_set($service, 'networks', $networks->toArray());
|
|
// Get variables from the service
|
|
foreach ($serviceVariables as $variableName => $variable) {
|
|
if (is_numeric($variableName)) {
|
|
if (is_array($variable)) {
|
|
// - SESSION_SECRET: 123
|
|
// - SESSION_SECRET:
|
|
$key = str(collect($variable)->keys()->first());
|
|
$value = str(collect($variable)->values()->first());
|
|
} else {
|
|
$variable = str($variable);
|
|
if ($variable->contains('=')) {
|
|
// - SESSION_SECRET=123
|
|
// - SESSION_SECRET=
|
|
$key = $variable->before('=');
|
|
$value = $variable->after('=');
|
|
} else {
|
|
// - SESSION_SECRET
|
|
$key = $variable;
|
|
$value = null;
|
|
}
|
|
}
|
|
} else {
|
|
// SESSION_SECRET: 123
|
|
// SESSION_SECRET:
|
|
$key = str($variableName);
|
|
$value = str($variable);
|
|
}
|
|
if ($key->startsWith('SERVICE_FQDN')) {
|
|
if ($isNew) {
|
|
$name = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower();
|
|
$fqdn = generateFqdn($server, "{$name->value()}-{$resource->uuid}");
|
|
if (substr_count($key->value(), '_') === 3) {
|
|
// SERVICE_FQDN_UMAMI_1000
|
|
$port = $key->afterLast('_');
|
|
} else {
|
|
// SERVICE_FQDN_UMAMI
|
|
$port = null;
|
|
}
|
|
if ($port) {
|
|
$fqdn = "$fqdn:$port";
|
|
}
|
|
if (substr_count($key->value(), '_') >= 2) {
|
|
if ($value) {
|
|
$path = $value->value();
|
|
} else {
|
|
$path = null;
|
|
}
|
|
if ($generatedServiceFQDNS->count() > 0) {
|
|
$alreadyGenerated = $generatedServiceFQDNS->has($key->value());
|
|
if ($alreadyGenerated) {
|
|
$fqdn = $generatedServiceFQDNS->get($key->value());
|
|
} else {
|
|
$generatedServiceFQDNS->put($key->value(), $fqdn);
|
|
}
|
|
} else {
|
|
$generatedServiceFQDNS->put($key->value(), $fqdn);
|
|
}
|
|
$fqdn = "$fqdn$path";
|
|
}
|
|
}
|
|
|
|
continue;
|
|
}
|
|
if ($value?->startsWith('$')) {
|
|
$foundEnv = EnvironmentVariable::where([
|
|
'key' => $key,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
'is_preview' => false,
|
|
])->first();
|
|
$value = replaceVariables($value);
|
|
$key = $value;
|
|
if ($value->startsWith('SERVICE_')) {
|
|
$foundEnv = EnvironmentVariable::where([
|
|
'key' => $key,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
])->first();
|
|
['command' => $command, 'forService' => $forService, 'generatedValue' => $generatedValue, 'port' => $port] = parseEnvVariable($value);
|
|
if (! is_null($command)) {
|
|
if ($command?->value() === 'FQDN' || $command?->value() === 'URL') {
|
|
if (Str::lower($forService) === $serviceName) {
|
|
$fqdn = generateFqdn($server, $containerName);
|
|
} else {
|
|
$fqdn = generateFqdn($server, Str::lower($forService).'-'.$resource->uuid);
|
|
}
|
|
if ($port) {
|
|
$fqdn = "$fqdn:$port";
|
|
}
|
|
if ($foundEnv) {
|
|
$fqdn = data_get($foundEnv, 'value');
|
|
} else {
|
|
if ($command?->value() === 'URL') {
|
|
$fqdn = str($fqdn)->after('://')->value();
|
|
}
|
|
EnvironmentVariable::create([
|
|
'key' => $key,
|
|
'value' => $fqdn,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
'is_preview' => false,
|
|
]);
|
|
}
|
|
} else {
|
|
$generatedValue = generateEnvValue($command);
|
|
if (! $foundEnv) {
|
|
EnvironmentVariable::create([
|
|
'key' => $key,
|
|
'value' => $generatedValue,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
'is_preview' => false,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if ($value->contains(':-')) {
|
|
$key = $value->before(':');
|
|
$defaultValue = $value->after(':-');
|
|
} elseif ($value->contains('-')) {
|
|
$key = $value->before('-');
|
|
$defaultValue = $value->after('-');
|
|
} elseif ($value->contains(':?')) {
|
|
$key = $value->before(':');
|
|
$defaultValue = $value->after(':?');
|
|
} elseif ($value->contains('?')) {
|
|
$key = $value->before('?');
|
|
$defaultValue = $value->after('?');
|
|
} else {
|
|
$key = $value;
|
|
$defaultValue = null;
|
|
}
|
|
$foundEnv = EnvironmentVariable::where([
|
|
'key' => $key,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
'is_preview' => false,
|
|
])->first();
|
|
if ($foundEnv) {
|
|
$defaultValue = data_get($foundEnv, 'value');
|
|
}
|
|
if ($foundEnv) {
|
|
$foundEnv->update([
|
|
'key' => $key,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
'value' => $defaultValue,
|
|
]);
|
|
} else {
|
|
EnvironmentVariable::create([
|
|
'key' => $key,
|
|
'value' => $defaultValue,
|
|
'resourceable_type' => get_class($resource),
|
|
'resourceable_id' => $resource->id,
|
|
'is_preview' => false,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Add labels to the service
|
|
if ($resource->serviceType()) {
|
|
$fqdns = generateServiceSpecificFqdns($resource);
|
|
} else {
|
|
$domains = collect(json_decode($resource->docker_compose_domains)) ?? [];
|
|
if ($domains) {
|
|
$fqdns = data_get($domains, "$serviceName.domain");
|
|
if ($fqdns) {
|
|
$fqdns = str($fqdns)->explode(',');
|
|
if ($pull_request_id !== 0) {
|
|
$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, "$serviceName.domain");
|
|
if ($found_fqdn) {
|
|
$fqdns = collect($found_fqdn);
|
|
} else {
|
|
$fqdns = collect([]);
|
|
}
|
|
} else {
|
|
$fqdns = $fqdns->map(function ($fqdn) use ($pull_request_id, $resource) {
|
|
$preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pull_request_id);
|
|
$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}}', $pull_request_id, $preview_fqdn);
|
|
$preview_fqdn = "$schema://$preview_fqdn";
|
|
$preview->fqdn = $preview_fqdn;
|
|
$preview->save();
|
|
|
|
return $preview_fqdn;
|
|
});
|
|
}
|
|
}
|
|
$shouldGenerateLabelsExactly = $server->settings->generate_exact_labels;
|
|
if ($shouldGenerateLabelsExactly) {
|
|
switch ($server->proxyType()) {
|
|
case ProxyTypes::TRAEFIK->value:
|
|
$serviceLabels = $serviceLabels->merge(
|
|
fqdnLabelsForTraefik(
|
|
uuid: $resource->uuid,
|
|
domains: $fqdns,
|
|
serviceLabels: $serviceLabels,
|
|
generate_unique_uuid: $resource->build_pack === 'dockercompose',
|
|
image: data_get($service, 'image'),
|
|
is_force_https_enabled: $resource->isForceHttpsEnabled(),
|
|
is_gzip_enabled: $resource->isGzipEnabled(),
|
|
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
|
|
)
|
|
);
|
|
break;
|
|
case ProxyTypes::CADDY->value:
|
|
$serviceLabels = $serviceLabels->merge(
|
|
fqdnLabelsForCaddy(
|
|
network: $resource->destination->network,
|
|
uuid: $resource->uuid,
|
|
domains: $fqdns,
|
|
serviceLabels: $serviceLabels,
|
|
image: data_get($service, 'image'),
|
|
is_force_https_enabled: $resource->isForceHttpsEnabled(),
|
|
is_gzip_enabled: $resource->isGzipEnabled(),
|
|
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
|
|
)
|
|
);
|
|
break;
|
|
}
|
|
} else {
|
|
$serviceLabels = $serviceLabels->merge(
|
|
fqdnLabelsForTraefik(
|
|
uuid: $resource->uuid,
|
|
domains: $fqdns,
|
|
serviceLabels: $serviceLabels,
|
|
generate_unique_uuid: $resource->build_pack === 'dockercompose',
|
|
image: data_get($service, 'image'),
|
|
is_force_https_enabled: $resource->isForceHttpsEnabled(),
|
|
is_gzip_enabled: $resource->isGzipEnabled(),
|
|
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
|
|
)
|
|
);
|
|
$serviceLabels = $serviceLabels->merge(
|
|
fqdnLabelsForCaddy(
|
|
network: $resource->destination->network,
|
|
uuid: $resource->uuid,
|
|
domains: $fqdns,
|
|
serviceLabels: $serviceLabels,
|
|
image: data_get($service, 'image'),
|
|
is_force_https_enabled: $resource->isForceHttpsEnabled(),
|
|
is_gzip_enabled: $resource->isGzipEnabled(),
|
|
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$defaultLabels = defaultLabels(
|
|
id: $resource->id,
|
|
name: $containerName,
|
|
projectName: $resource->project()->name,
|
|
resourceName: $resource->name,
|
|
environment: $resource->environment->name,
|
|
pull_request_id: $pull_request_id,
|
|
type: 'application'
|
|
);
|
|
$serviceLabels = $serviceLabels->merge($defaultLabels);
|
|
|
|
if ($server->isLogDrainEnabled()) {
|
|
if ($resource instanceof Application && $resource->isLogDrainEnabled()) {
|
|
data_set($service, 'logging', generate_fluentd_configuration());
|
|
}
|
|
}
|
|
if ($serviceLabels->count() > 0) {
|
|
if ($resource->settings->is_container_label_escape_enabled) {
|
|
$serviceLabels = $serviceLabels->map(function ($value, $key) {
|
|
return escapeDollarSign($value);
|
|
});
|
|
}
|
|
}
|
|
data_set($service, 'labels', $serviceLabels->toArray());
|
|
data_forget($service, 'is_database');
|
|
if (! data_get($service, 'restart')) {
|
|
data_set($service, 'restart', RESTART_MODE);
|
|
}
|
|
data_set($service, 'container_name', $containerName);
|
|
data_forget($service, 'volumes.*.content');
|
|
data_forget($service, 'volumes.*.isDirectory');
|
|
data_forget($service, 'volumes.*.is_directory');
|
|
data_forget($service, 'exclude_from_hc');
|
|
data_set($service, 'environment', $serviceVariables->toArray());
|
|
|
|
return $service;
|
|
});
|
|
if ($pull_request_id !== 0) {
|
|
$services->each(function ($service, $serviceName) use ($pull_request_id, $services) {
|
|
$services[addPreviewDeploymentSuffix($serviceName, $pull_request_id)] = $service;
|
|
data_forget($services, $serviceName);
|
|
});
|
|
}
|
|
$finalServices = [
|
|
'services' => $services->toArray(),
|
|
'volumes' => $topLevelVolumes->toArray(),
|
|
'networks' => $topLevelNetworks->toArray(),
|
|
'configs' => $topLevelConfigs->toArray(),
|
|
'secrets' => $topLevelSecrets->toArray(),
|
|
];
|
|
$resource->docker_compose_raw = Yaml::dump($yaml, 10, 2);
|
|
$resource->docker_compose = Yaml::dump($finalServices, 10, 2);
|
|
data_forget($resource, 'environment_variables');
|
|
data_forget($resource, 'environment_variables_preview');
|
|
$resource->save();
|
|
|
|
return collect($finalServices);
|
|
}
|
|
}
|
|
|
|
function generate_fluentd_configuration(): array
|
|
{
|
|
return [
|
|
'driver' => 'fluentd',
|
|
'options' => [
|
|
'fluentd-address' => 'tcp://127.0.0.1:24224',
|
|
'fluentd-async' => 'true',
|
|
'fluentd-sub-second-precision' => 'true',
|
|
// env vars are used in the LogDrain configurations
|
|
'env' => 'COOLIFY_APP_NAME,COOLIFY_PROJECT_NAME,COOLIFY_SERVER_IP,COOLIFY_ENVIRONMENT_NAME',
|
|
],
|
|
];
|
|
}
|
|
|
|
function isAssociativeArray($array)
|
|
{
|
|
if ($array instanceof Collection) {
|
|
$array = $array->toArray();
|
|
}
|
|
|
|
if (! is_array($array)) {
|
|
throw new \InvalidArgumentException('Input must be an array or a Collection.');
|
|
}
|
|
|
|
if ($array === []) {
|
|
return false;
|
|
}
|
|
|
|
return array_keys($array) !== range(0, count($array) - 1);
|
|
}
|
|
|
|
/**
|
|
* This method adds the default environment variables to the resource.
|
|
* - COOLIFY_APP_NAME
|
|
* - COOLIFY_PROJECT_NAME
|
|
* - COOLIFY_SERVER_IP
|
|
* - COOLIFY_ENVIRONMENT_NAME
|
|
*
|
|
* Theses variables are added in place to the $where_to_add array.
|
|
*/
|
|
function add_coolify_default_environment_variables(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|Application|Service $resource, Collection &$where_to_add, ?Collection $where_to_check = null)
|
|
{
|
|
// Currently disabled
|
|
return;
|
|
if ($resource instanceof Service) {
|
|
$ip = $resource->server->ip;
|
|
} else {
|
|
$ip = $resource->destination->server->ip;
|
|
}
|
|
if (isAssociativeArray($where_to_add)) {
|
|
$isAssociativeArray = true;
|
|
} else {
|
|
$isAssociativeArray = false;
|
|
}
|
|
if ($where_to_check != null && $where_to_check->where('key', 'COOLIFY_APP_NAME')->isEmpty()) {
|
|
if ($isAssociativeArray) {
|
|
$where_to_add->put('COOLIFY_APP_NAME', "\"{$resource->name}\"");
|
|
} else {
|
|
$where_to_add->push("COOLIFY_APP_NAME=\"{$resource->name}\"");
|
|
}
|
|
}
|
|
if ($where_to_check != null && $where_to_check->where('key', 'COOLIFY_SERVER_IP')->isEmpty()) {
|
|
if ($isAssociativeArray) {
|
|
$where_to_add->put('COOLIFY_SERVER_IP', "\"{$ip}\"");
|
|
} else {
|
|
$where_to_add->push("COOLIFY_SERVER_IP=\"{$ip}\"");
|
|
}
|
|
}
|
|
if ($where_to_check != null && $where_to_check->where('key', 'COOLIFY_ENVIRONMENT_NAME')->isEmpty()) {
|
|
if ($isAssociativeArray) {
|
|
$where_to_add->put('COOLIFY_ENVIRONMENT_NAME', "\"{$resource->environment->name}\"");
|
|
} else {
|
|
$where_to_add->push("COOLIFY_ENVIRONMENT_NAME=\"{$resource->environment->name}\"");
|
|
}
|
|
}
|
|
if ($where_to_check != null && $where_to_check->where('key', 'COOLIFY_PROJECT_NAME')->isEmpty()) {
|
|
if ($isAssociativeArray) {
|
|
$where_to_add->put('COOLIFY_PROJECT_NAME', "\"{$resource->project()->name}\"");
|
|
} else {
|
|
$where_to_add->push("COOLIFY_PROJECT_NAME=\"{$resource->project()->name}\"");
|
|
}
|
|
}
|
|
}
|
|
|
|
function convertToKeyValueCollection($environment)
|
|
{
|
|
$convertedServiceVariables = collect([]);
|
|
if (isAssociativeArray($environment)) {
|
|
// Example: $environment = ['FOO' => 'bar', 'BAZ' => 'qux'];
|
|
if ($environment instanceof Collection) {
|
|
$changedEnvironment = collect([]);
|
|
$environment->each(function ($value, $key) use ($changedEnvironment) {
|
|
if (is_numeric($key)) {
|
|
$parts = explode('=', $value, 2);
|
|
if (count($parts) === 2) {
|
|
$key = $parts[0];
|
|
$realValue = $parts[1] ?? '';
|
|
$changedEnvironment->put($key, $realValue);
|
|
} else {
|
|
$changedEnvironment->put($key, $value);
|
|
}
|
|
} else {
|
|
$changedEnvironment->put($key, $value);
|
|
}
|
|
});
|
|
|
|
return $changedEnvironment;
|
|
}
|
|
$convertedServiceVariables = $environment;
|
|
} else {
|
|
// Example: $environment = ['FOO=bar', 'BAZ=qux'];
|
|
foreach ($environment as $value) {
|
|
if (is_string($value)) {
|
|
$parts = explode('=', $value, 2);
|
|
$key = $parts[0];
|
|
$realValue = $parts[1] ?? '';
|
|
if ($key) {
|
|
$convertedServiceVariables->put($key, $realValue);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $convertedServiceVariables;
|
|
}
|
|
function instanceSettings()
|
|
{
|
|
return InstanceSettings::get();
|
|
}
|
|
|
|
function wireNavigate(): string
|
|
{
|
|
try {
|
|
$settings = instanceSettings();
|
|
|
|
// Return wire:navigate.hover for SPA navigation with prefetching, or empty string if disabled
|
|
return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate.hover' : '';
|
|
} catch (\Exception $e) {
|
|
return 'wire:navigate.hover';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Redirect to a named route with SPA navigation support.
|
|
* Automatically uses wire:navigate when is_wire_navigate_enabled is true.
|
|
*/
|
|
function redirectRoute(Livewire\Component $component, string $name, array $parameters = []): mixed
|
|
{
|
|
$navigate = true;
|
|
|
|
try {
|
|
$navigate = instanceSettings()->is_wire_navigate_enabled ?? true;
|
|
} catch (\Exception $e) {
|
|
$navigate = true;
|
|
}
|
|
|
|
return $component->redirectRoute($name, $parameters, navigate: $navigate);
|
|
}
|
|
|
|
function getHelperVersion(): string
|
|
{
|
|
$settings = instanceSettings();
|
|
|
|
// In development mode, use the dev_helper_version if set, otherwise fallback to config
|
|
if (isDev() && ! empty($settings->dev_helper_version)) {
|
|
return $settings->dev_helper_version;
|
|
}
|
|
|
|
return config('constants.coolify.helper_version');
|
|
}
|
|
|
|
function loadConfigFromGit(string $repository, string $branch, string $base_directory, int $server_id, int $team_id): ?array
|
|
{
|
|
$server = Server::where('id', $server_id)->where('team_id', $team_id)->first();
|
|
if (! $server) {
|
|
Log::warning("coolify.json: Server not found - server_id: {$server_id}, team_id: {$team_id}");
|
|
|
|
return null;
|
|
}
|
|
|
|
$uuid = new Cuid2;
|
|
|
|
// Escape shell arguments for safety to prevent command injection
|
|
$escapedBranch = escapeshellarg($branch);
|
|
$escapedRepository = escapeshellarg($repository);
|
|
$cloneCommand = "git clone --no-checkout -b {$escapedBranch} {$escapedRepository} .";
|
|
$workdir = rtrim($base_directory, '/');
|
|
|
|
// Build paths to check: base_directory/coolify.json first, then repo root
|
|
$pathsToCheck = [];
|
|
if ($workdir !== '' && $workdir !== '/') {
|
|
$pathsToCheck[] = ltrim($workdir, '/').'/coolify.json';
|
|
}
|
|
$pathsToCheck[] = 'coolify.json';
|
|
|
|
// Build sparse-checkout file list (escaped for shell safety)
|
|
$fileList = collect($pathsToCheck)->map(fn ($path) => escapeshellarg("./{$path}"))->implode(' ');
|
|
|
|
$commands = collect([
|
|
"rm -rf /tmp/{$uuid}",
|
|
"mkdir -p /tmp/{$uuid}",
|
|
"cd /tmp/{$uuid}",
|
|
$cloneCommand,
|
|
'git sparse-checkout init --cone',
|
|
"git sparse-checkout set {$fileList}",
|
|
'git read-tree -mu HEAD',
|
|
]);
|
|
|
|
// Add cat commands for each path, trying base_directory first
|
|
// Use subshell to capture output before cleanup (paths escaped for shell safety)
|
|
$catCommands = collect($pathsToCheck)->map(fn ($path) => 'cat '.escapeshellarg("./{$path}").' 2>/dev/null')->implode(' || ');
|
|
$commands->push("({$catCommands}) || true");
|
|
$commands->push("cd / && rm -rf /tmp/{$uuid}");
|
|
|
|
try {
|
|
$output = instant_remote_process($commands, $server);
|
|
|
|
if (empty($output)) {
|
|
return null;
|
|
}
|
|
|
|
$config = json_decode($output, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
Log::warning('coolify.json: Invalid JSON format - '.json_last_error_msg().' - output was: '.substr($output, 0, 200));
|
|
|
|
return null;
|
|
}
|
|
|
|
// Validate schema version
|
|
$version = data_get($config, 'version', '1.0');
|
|
$supportedVersions = ['1.0'];
|
|
if (! in_array($version, $supportedVersions)) {
|
|
Log::warning('coolify.json: Unsupported schema version - '.$version);
|
|
|
|
return null;
|
|
}
|
|
|
|
// Warn about unknown top-level fields
|
|
$knownFields = [
|
|
'version',
|
|
'name',
|
|
'description',
|
|
'source',
|
|
'build',
|
|
'domains',
|
|
'network_aliases',
|
|
'http_basic_auth',
|
|
'health_check',
|
|
'limits',
|
|
'settings',
|
|
'deployment_commands',
|
|
'preview',
|
|
'swarm',
|
|
'docker_registry',
|
|
'persistent_storages',
|
|
'file_mounts',
|
|
'directory_mounts',
|
|
'scheduled_tasks',
|
|
'environment_variables',
|
|
];
|
|
$unknownFields = array_diff(array_keys($config), $knownFields);
|
|
|
|
return $config;
|
|
} catch (\Exception $e) {
|
|
Log::warning('coolify.json: Exception during detection - '.$e->getMessage());
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function loggy($message = null, array $context = [])
|
|
{
|
|
if (! isDev()) {
|
|
return;
|
|
}
|
|
if (function_exists('ray') && config('app.debug')) {
|
|
ray($message, $context);
|
|
}
|
|
if (is_null($message)) {
|
|
return app('log');
|
|
}
|
|
|
|
return app('log')->debug($message, $context);
|
|
}
|
|
function sslipDomainWarning(string $domains)
|
|
{
|
|
$domains = str($domains)->trim()->explode(',');
|
|
$showSslipHttpsWarning = false;
|
|
$domains->each(function ($domain) use (&$showSslipHttpsWarning) {
|
|
if (str($domain)->contains('https') && str($domain)->contains('sslip')) {
|
|
$showSslipHttpsWarning = true;
|
|
}
|
|
});
|
|
|
|
return $showSslipHttpsWarning;
|
|
}
|
|
|
|
function isEmailRateLimited(string $limiterKey, int $decaySeconds = 3600, ?callable $callbackOnSuccess = null): bool
|
|
{
|
|
if (isDev()) {
|
|
$decaySeconds = 120;
|
|
}
|
|
$rateLimited = false;
|
|
$executed = RateLimiter::attempt(
|
|
$limiterKey,
|
|
$maxAttempts = 0,
|
|
function () use (&$rateLimited, &$limiterKey, $callbackOnSuccess) {
|
|
isDev() && loggy('Rate limit not reached for '.$limiterKey);
|
|
$rateLimited = false;
|
|
|
|
if ($callbackOnSuccess) {
|
|
$callbackOnSuccess();
|
|
}
|
|
},
|
|
$decaySeconds,
|
|
);
|
|
if (! $executed) {
|
|
isDev() && loggy('Rate limit reached for '.$limiterKey.'. Rate limiter will be disabled for '.$decaySeconds.' seconds.');
|
|
$rateLimited = true;
|
|
}
|
|
|
|
return $rateLimited;
|
|
}
|
|
|
|
function defaultNginxConfiguration(string $type = 'static'): string
|
|
{
|
|
if ($type === 'spa') {
|
|
return <<<'NGINX'
|
|
server {
|
|
location / {
|
|
root /usr/share/nginx/html;
|
|
index index.html;
|
|
try_files $uri $uri/ /index.html;
|
|
}
|
|
|
|
# Handle 404 errors
|
|
error_page 404 /404.html;
|
|
location = /404.html {
|
|
root /usr/share/nginx/html;
|
|
internal;
|
|
}
|
|
|
|
# Handle server errors (50x)
|
|
error_page 500 502 503 504 /50x.html;
|
|
location = /50x.html {
|
|
root /usr/share/nginx/html;
|
|
internal;
|
|
}
|
|
}
|
|
NGINX;
|
|
} else {
|
|
return <<<'NGINX'
|
|
server {
|
|
location / {
|
|
root /usr/share/nginx/html;
|
|
index index.html index.htm;
|
|
try_files $uri $uri.html $uri/index.html $uri/index.htm $uri/ =404;
|
|
}
|
|
|
|
# Handle 404 errors
|
|
error_page 404 /404.html;
|
|
location = /404.html {
|
|
root /usr/share/nginx/html;
|
|
internal;
|
|
}
|
|
|
|
# Handle server errors (50x)
|
|
error_page 500 502 503 504 /50x.html;
|
|
location = /50x.html {
|
|
root /usr/share/nginx/html;
|
|
internal;
|
|
}
|
|
}
|
|
NGINX;
|
|
}
|
|
}
|
|
|
|
function convertGitUrl(string $gitRepository, string $deploymentType, ?GithubApp $source = null): array
|
|
{
|
|
$repository = $gitRepository;
|
|
$providerInfo = [
|
|
'host' => null,
|
|
'user' => 'git',
|
|
'port' => 22,
|
|
'repository' => $gitRepository,
|
|
];
|
|
$sshMatches = [];
|
|
$matches = [];
|
|
|
|
// Let's try and parse the string to detect if it's a valid SSH string or not
|
|
preg_match('/((.*?)\:\/\/)?(.*@.*:.*)/', $gitRepository, $sshMatches);
|
|
|
|
if ($deploymentType === 'deploy_key' && empty($sshMatches) && $source) {
|
|
// If this happens, the user may have provided an HTTP URL when they needed an SSH one
|
|
// Let's try and fix that for known Git providers
|
|
switch ($source->getMorphClass()) {
|
|
case \App\Models\GithubApp::class:
|
|
$providerInfo['host'] = Url::fromString($source->html_url)->getHost();
|
|
$providerInfo['port'] = $source->custom_port;
|
|
$providerInfo['user'] = $source->custom_user;
|
|
break;
|
|
}
|
|
if (! empty($providerInfo['host'])) {
|
|
// Until we do not support more providers with App (like GithubApp), this will be always true, port will be 22
|
|
if ($providerInfo['port'] === 22) {
|
|
$repository = "{$providerInfo['user']}@{$providerInfo['host']}:{$providerInfo['repository']}";
|
|
} else {
|
|
$repository = "ssh://{$providerInfo['user']}@{$providerInfo['host']}:{$providerInfo['port']}/{$providerInfo['repository']}";
|
|
}
|
|
}
|
|
}
|
|
|
|
preg_match('/(?<=:)\d+(?=\/)/', $gitRepository, $matches);
|
|
|
|
if (count($matches) === 1) {
|
|
$providerInfo['port'] = $matches[0];
|
|
$gitHost = str($gitRepository)->before(':');
|
|
$gitRepo = str($gitRepository)->after('/');
|
|
$repository = "$gitHost:$gitRepo";
|
|
}
|
|
|
|
return [
|
|
'repository' => $repository,
|
|
'port' => $providerInfo['port'],
|
|
];
|
|
}
|
|
|
|
function getJobStatus(?string $jobId = null)
|
|
{
|
|
if (blank($jobId)) {
|
|
return 'unknown';
|
|
}
|
|
$jobFound = app(JobRepository::class)->getJobs([$jobId]);
|
|
if ($jobFound->isEmpty()) {
|
|
return 'unknown';
|
|
}
|
|
|
|
return $jobFound->first()->status;
|
|
}
|
|
|
|
function parseDockerfileInterval(string $something)
|
|
{
|
|
$value = preg_replace('/[^0-9]/', '', $something);
|
|
$unit = preg_replace('/[0-9]/', '', $something);
|
|
|
|
// Default to seconds if no unit specified
|
|
$unit = $unit ?: 's';
|
|
|
|
// Convert to seconds based on unit
|
|
$seconds = (int) $value;
|
|
switch ($unit) {
|
|
case 'ns':
|
|
$seconds = (int) ($value / 1000000000);
|
|
break;
|
|
case 'us':
|
|
case 'µs':
|
|
$seconds = (int) ($value / 1000000);
|
|
break;
|
|
case 'ms':
|
|
$seconds = (int) ($value / 1000);
|
|
break;
|
|
case 'm':
|
|
$seconds = (int) ($value * 60);
|
|
break;
|
|
case 'h':
|
|
$seconds = (int) ($value * 3600);
|
|
break;
|
|
}
|
|
|
|
return $seconds;
|
|
}
|
|
|
|
function addPreviewDeploymentSuffix(string $name, int $pull_request_id = 0): string
|
|
{
|
|
return ($pull_request_id === 0) ? $name : $name.'-pr-'.$pull_request_id;
|
|
}
|
|
|
|
function generateDockerComposeServiceName(mixed $services, int $pullRequestId = 0): Collection
|
|
{
|
|
$collection = collect([]);
|
|
foreach ($services as $serviceName => $_) {
|
|
$collection->put('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper(), addPreviewDeploymentSuffix($serviceName, $pullRequestId));
|
|
}
|
|
|
|
return $collection;
|
|
}
|
|
|
|
function formatBytes(?int $bytes, int $precision = 2): string
|
|
{
|
|
if ($bytes === null || $bytes === 0) {
|
|
return '0 B';
|
|
}
|
|
|
|
// Handle negative numbers
|
|
if ($bytes < 0) {
|
|
return '0 B';
|
|
}
|
|
|
|
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
$base = 1024;
|
|
$exponent = floor(log($bytes) / log($base));
|
|
$exponent = min($exponent, count($units) - 1);
|
|
|
|
$value = $bytes / pow($base, $exponent);
|
|
|
|
return round($value, $precision).' '.$units[$exponent];
|
|
}
|
|
|
|
/**
|
|
* Validates that a file path is safely within the /tmp/ directory.
|
|
* Protects against path traversal attacks by resolving the real path
|
|
* and verifying it stays within /tmp/.
|
|
*
|
|
* Note: On macOS, /tmp is often a symlink to /private/tmp, which is handled.
|
|
*/
|
|
function isSafeTmpPath(?string $path): bool
|
|
{
|
|
if (blank($path)) {
|
|
return false;
|
|
}
|
|
|
|
// URL decode to catch encoded traversal attempts
|
|
$decodedPath = urldecode($path);
|
|
|
|
// Minimum length check - /tmp/x is 6 chars
|
|
if (strlen($decodedPath) < 6) {
|
|
return false;
|
|
}
|
|
|
|
// Must start with /tmp/
|
|
if (! str($decodedPath)->startsWith('/tmp/')) {
|
|
return false;
|
|
}
|
|
|
|
// Quick check for obvious traversal attempts
|
|
if (str($decodedPath)->contains('..')) {
|
|
return false;
|
|
}
|
|
|
|
// Check for null bytes (directory traversal technique)
|
|
if (str($decodedPath)->contains("\0")) {
|
|
return false;
|
|
}
|
|
|
|
// Remove any trailing slashes for consistent validation
|
|
$normalizedPath = rtrim($decodedPath, '/');
|
|
|
|
// Normalize the path by removing redundant separators and resolving . and ..
|
|
// We'll do this manually since realpath() requires the path to exist
|
|
$parts = explode('/', $normalizedPath);
|
|
$resolvedParts = [];
|
|
|
|
foreach ($parts as $part) {
|
|
if ($part === '' || $part === '.') {
|
|
// Skip empty parts (from //) and current directory references
|
|
continue;
|
|
} elseif ($part === '..') {
|
|
// Parent directory - this should have been caught earlier but double-check
|
|
return false;
|
|
} else {
|
|
$resolvedParts[] = $part;
|
|
}
|
|
}
|
|
|
|
$resolvedPath = '/'.implode('/', $resolvedParts);
|
|
|
|
// Final check: resolved path must start with /tmp/
|
|
// And must have at least one component after /tmp/
|
|
if (! str($resolvedPath)->startsWith('/tmp/') || $resolvedPath === '/tmp') {
|
|
return false;
|
|
}
|
|
|
|
// Resolve the canonical /tmp path (handles symlinks like /tmp -> /private/tmp on macOS)
|
|
$canonicalTmpPath = realpath('/tmp');
|
|
if ($canonicalTmpPath === false) {
|
|
// If /tmp doesn't exist, something is very wrong, but allow non-existing paths
|
|
$canonicalTmpPath = '/tmp';
|
|
}
|
|
|
|
// Calculate dirname once to avoid redundant calls
|
|
$dirPath = dirname($resolvedPath);
|
|
|
|
// If the directory exists, resolve it via realpath to catch symlink attacks
|
|
if (is_dir($dirPath)) {
|
|
// For existing paths, resolve to absolute path to catch symlinks
|
|
$realDir = realpath($dirPath);
|
|
if ($realDir === false) {
|
|
return false;
|
|
}
|
|
|
|
// Check if the real directory is within /tmp (or its canonical path)
|
|
if (! str($realDir)->startsWith('/tmp') && ! str($realDir)->startsWith($canonicalTmpPath)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Transform colon-delimited status format to human-readable parentheses format.
|
|
*
|
|
* Handles Docker container status formats with optional health check status and exclusion modifiers.
|
|
*
|
|
* Examples:
|
|
* - running:healthy → Running (healthy)
|
|
* - running:unhealthy:excluded → Running (unhealthy, excluded)
|
|
* - exited:excluded → Exited (excluded)
|
|
* - Proxy:running → Proxy:running (preserved as-is for headline formatting)
|
|
* - running → Running
|
|
*
|
|
* @param string $status The status string to format
|
|
* @return string The formatted status string
|
|
*/
|
|
function formatContainerStatus(string $status): string
|
|
{
|
|
// Preserve Proxy statuses as-is (they follow different format)
|
|
if (str($status)->startsWith('Proxy')) {
|
|
return str($status)->headline()->value();
|
|
}
|
|
|
|
// Check for :excluded suffix
|
|
$isExcluded = str($status)->endsWith(':excluded');
|
|
$parts = explode(':', $status);
|
|
|
|
if ($isExcluded) {
|
|
if (count($parts) === 3) {
|
|
// Has health status: running:unhealthy:excluded → Running (unhealthy, excluded)
|
|
return str($parts[0])->headline().' ('.$parts[1].', excluded)';
|
|
} else {
|
|
// No health status: exited:excluded → Exited (excluded)
|
|
return str($parts[0])->headline().' (excluded)';
|
|
}
|
|
} elseif (count($parts) >= 2) {
|
|
// Regular colon format: running:healthy → Running (healthy)
|
|
return str($parts[0])->headline().' ('.$parts[1].')';
|
|
} else {
|
|
// Simple status: running → Running
|
|
return str($status)->headline()->value();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if password confirmation should be skipped.
|
|
* Returns true if:
|
|
* - Two-step confirmation is globally disabled
|
|
* - User has no password (OAuth users)
|
|
*
|
|
* Used by modal-confirmation.blade.php to determine if password step should be shown.
|
|
*
|
|
* @return bool True if password confirmation should be skipped
|
|
*/
|
|
function shouldSkipPasswordConfirmation(): bool
|
|
{
|
|
// Skip if two-step confirmation is globally disabled
|
|
if (data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
|
return true;
|
|
}
|
|
|
|
// Skip if user has no password (OAuth users)
|
|
if (! Auth::user()?->hasPassword()) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Verify password for two-step confirmation.
|
|
* Skips verification if:
|
|
* - Two-step confirmation is globally disabled
|
|
* - User has no password (OAuth users)
|
|
*
|
|
* @param mixed $password The password to verify (may be array if skipped by frontend)
|
|
* @param \Livewire\Component|null $component Optional Livewire component to add errors to
|
|
* @return bool True if verification passed (or skipped), false if password is incorrect
|
|
*/
|
|
function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $component = null): bool
|
|
{
|
|
// Skip if password confirmation should be skipped
|
|
if (shouldSkipPasswordConfirmation()) {
|
|
return true;
|
|
}
|
|
|
|
// Verify the password
|
|
if (! Hash::check($password, Auth::user()->password)) {
|
|
if ($component) {
|
|
$component->addError('password', 'The provided password is incorrect.');
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|