mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 12:16:45 +00:00
Merge remote-tracking branch 'origin/next' into feat/api-move-resources-between-projects
This commit is contained in:
commit
cf63c7d154
1315 files changed
+106601
-22122
No files matched your search
+51
-19
@@ -3,15 +3,24 @@
|
||||
use App\Enums\BuildPackTypes;
|
||||
use App\Enums\RedirectTypes;
|
||||
use App\Enums\StaticImageTypes;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
function getTeamIdFromToken()
|
||||
{
|
||||
$token = auth()->user()->currentAccessToken();
|
||||
$user = auth()->user();
|
||||
$token = $user?->currentAccessToken();
|
||||
$teamId = data_get($token, 'team_id');
|
||||
|
||||
return data_get($token, 'team_id');
|
||||
if (! $user || is_null($teamId) || ! $user->teams()->where('teams.id', $teamId)->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $teamId;
|
||||
}
|
||||
function invalidTokenResponse()
|
||||
{
|
||||
@@ -79,34 +88,49 @@ function serializeApiResponse($data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-expose a model's `$hidden` sensitive fields when the current API request
|
||||
* carries the `read:sensitive` or `root` token ability (set by the
|
||||
* ApiSensitiveData middleware).
|
||||
*/
|
||||
function exposeSensitiveFields(Model $model): Model
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true && filled($model->getHidden())) {
|
||||
$model->makeVisible($model->getHidden());
|
||||
}
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
function sharedDataApplications()
|
||||
{
|
||||
return [
|
||||
'git_repository' => 'string',
|
||||
'git_branch' => 'string',
|
||||
'git_branch' => ['string', new ValidGitBranch],
|
||||
'build_pack' => Rule::enum(BuildPackTypes::class),
|
||||
'is_static' => 'boolean',
|
||||
'is_spa' => 'boolean',
|
||||
'is_auto_deploy_enabled' => 'boolean',
|
||||
'is_force_https_enabled' => 'boolean',
|
||||
'is_preview_deployments_enabled' => 'boolean',
|
||||
'static_image' => Rule::enum(StaticImageTypes::class),
|
||||
'domains' => 'string|nullable',
|
||||
'domains' => ValidationPatterns::applicationDomainRules(),
|
||||
'redirect' => Rule::enum(RedirectTypes::class),
|
||||
'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
|
||||
'docker_registry_image_name' => 'string|nullable',
|
||||
'docker_registry_image_tag' => 'string|nullable',
|
||||
'install_command' => 'string|nullable',
|
||||
'build_command' => 'string|nullable',
|
||||
'start_command' => 'string|nullable',
|
||||
'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(),
|
||||
'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
|
||||
'install_command' => ValidationPatterns::shellSafeCommandRules(),
|
||||
'build_command' => ValidationPatterns::shellSafeCommandRules(),
|
||||
'start_command' => ValidationPatterns::shellSafeCommandRules(),
|
||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/',
|
||||
'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable',
|
||||
'custom_network_aliases' => 'string|nullable',
|
||||
'base_directory' => 'string|nullable',
|
||||
'publish_directory' => 'string|nullable',
|
||||
'base_directory' => ValidationPatterns::directoryPathRules(),
|
||||
'publish_directory' => ValidationPatterns::directoryPathRules(),
|
||||
'health_check_enabled' => 'boolean',
|
||||
'health_check_type' => 'string|in:http,cmd',
|
||||
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
|
||||
'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
|
||||
'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'],
|
||||
'health_check_port' => 'integer|nullable|min:1|max:65535',
|
||||
'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
|
||||
'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS',
|
||||
@@ -125,22 +149,26 @@ function sharedDataApplications()
|
||||
'limits_cpuset' => 'string|nullable',
|
||||
'limits_cpu_shares' => 'numeric',
|
||||
'custom_labels' => 'string|nullable',
|
||||
'custom_docker_run_options' => 'string|nullable',
|
||||
'custom_docker_run_options' => ValidationPatterns::shellSafeCommandRules(2000),
|
||||
// Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate").
|
||||
// Access is gated by API token authentication. Commands run inside the app container, not the host.
|
||||
'post_deployment_command' => 'string|nullable',
|
||||
'post_deployment_command_container' => 'string',
|
||||
'post_deployment_command_container' => ValidationPatterns::containerNameRules(),
|
||||
'pre_deployment_command' => 'string|nullable',
|
||||
'pre_deployment_command_container' => 'string',
|
||||
'pre_deployment_command_container' => ValidationPatterns::containerNameRules(),
|
||||
'manual_webhook_secret_github' => 'string|nullable',
|
||||
'manual_webhook_secret_gitlab' => 'string|nullable',
|
||||
'manual_webhook_secret_bitbucket' => 'string|nullable',
|
||||
'manual_webhook_secret_gitea' => 'string|nullable',
|
||||
'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN],
|
||||
'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN],
|
||||
'dockerfile_location' => ValidationPatterns::filePathRules(),
|
||||
'dockerfile_target_build' => ValidationPatterns::dockerTargetRules(),
|
||||
'docker_compose_location' => ValidationPatterns::filePathRules(),
|
||||
'docker_compose' => 'string|nullable',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_custom_start_command' => 'string|nullable',
|
||||
'docker_compose_custom_build_command' => 'string|nullable',
|
||||
'docker_compose_custom_start_command' => ValidationPatterns::shellSafeCommandRules(),
|
||||
'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(),
|
||||
'is_container_label_escape_enabled' => 'boolean',
|
||||
'is_preserve_repository_enabled' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -230,9 +258,13 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
|
||||
$request->offsetUnset('is_spa');
|
||||
$request->offsetUnset('is_auto_deploy_enabled');
|
||||
$request->offsetUnset('is_force_https_enabled');
|
||||
$request->offsetUnset('is_preview_deployments_enabled');
|
||||
$request->offsetUnset('connect_to_docker_network');
|
||||
$request->offsetUnset('force_domain_override');
|
||||
$request->offsetUnset('autogenerate_domain');
|
||||
$request->offsetUnset('is_container_label_escape_enabled');
|
||||
$request->offsetUnset('is_preserve_repository_enabled');
|
||||
$request->offsetUnset('include_source_commit_in_build');
|
||||
$request->offsetUnset('docker_compose_raw');
|
||||
$request->offsetUnset('tags');
|
||||
}
|
||||
@@ -6,13 +6,14 @@ use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Jobs\VolumeCloneJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use Spatie\Url\Url;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false)
|
||||
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, ?string $commit = null, bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null)
|
||||
{
|
||||
$commit = $commit ?: ($application->git_commit_sha ?: 'HEAD');
|
||||
$application_id = $application->id;
|
||||
$deployment_link = Url::fromString($application->link()."/deployment/{$deployment_uuid}");
|
||||
$deployment_url = $deployment_link->getPath();
|
||||
@@ -46,6 +47,7 @@ function queue_application_deployment(Application $application, string $deployme
|
||||
$existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id)
|
||||
->where('commit', $commit)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->where('docker_registry_image_tag', $docker_registry_image_tag)
|
||||
->whereIn('status', [ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value])
|
||||
->first();
|
||||
|
||||
@@ -71,6 +73,7 @@ function queue_application_deployment(Application $application, string $deployme
|
||||
'deployment_uuid' => $deployment_uuid,
|
||||
'deployment_url' => $deployment_url,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'docker_registry_image_tag' => $docker_registry_image_tag,
|
||||
'force_rebuild' => $force_rebuild,
|
||||
'is_webhook' => $is_webhook,
|
||||
'is_api' => $is_api,
|
||||
@@ -188,11 +191,11 @@ function next_after_cancel(?Server $server = null)
|
||||
|
||||
function clone_application(Application $source, $destination, array $overrides = [], bool $cloneVolumeData = false): Application
|
||||
{
|
||||
$uuid = $overrides['uuid'] ?? (string) new Cuid2;
|
||||
$uuid = $overrides['uuid'] ?? new_public_id();
|
||||
$server = $destination->server;
|
||||
|
||||
if ($server->team_id !== currentTeam()->id) {
|
||||
throw new \RuntimeException('Destination does not belong to the current team.');
|
||||
throw new RuntimeException('Destination does not belong to the current team.');
|
||||
}
|
||||
|
||||
// Prepare name and URL
|
||||
@@ -238,6 +241,7 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
'application_id' => $newApplication->id,
|
||||
]);
|
||||
$newApplicationSettings->save();
|
||||
$newApplication->setRelation('settings', $newApplicationSettings->fresh());
|
||||
}
|
||||
|
||||
// Clone tags
|
||||
@@ -254,7 +258,7 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'uuid' => new_public_id(),
|
||||
'application_id' => $newApplication->id,
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
@@ -269,7 +273,7 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])->fill([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'uuid' => new_public_id(),
|
||||
'application_id' => $newApplication->id,
|
||||
'status' => 'exited',
|
||||
'fqdn' => null,
|
||||
@@ -299,6 +303,7 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'uuid',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $newApplication->id,
|
||||
@@ -316,14 +321,14 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
|
||||
|
||||
queue_application_deployment(
|
||||
deployment_uuid: (string) new Cuid2,
|
||||
deployment_uuid: new_public_id(),
|
||||
application: $source,
|
||||
server: $sourceServer,
|
||||
destination: $source->destination,
|
||||
no_questions_asked: true
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,7 +349,7 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
// Clone production environment variables without triggering the created hook
|
||||
$environmentVariables = $source->environment_variables()->get();
|
||||
foreach ($environmentVariables as $environmentVariable) {
|
||||
\App\Models\EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) {
|
||||
EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) {
|
||||
$newEnvironmentVariable = $environmentVariable->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
@@ -361,7 +366,7 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
// Clone preview environment variables
|
||||
$previewEnvironmentVariables = $source->environment_variables_preview()->get();
|
||||
foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) {
|
||||
\App\Models\EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) {
|
||||
EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) {
|
||||
$newPreviewEnvironmentVariable = $previewEnvironmentVariable->replicate([
|
||||
'id',
|
||||
'created_at',
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
if (! function_exists('auditLog')) {
|
||||
/**
|
||||
* Write a security-relevant audit entry to the dedicated `audit` log channel.
|
||||
*
|
||||
* Never include secrets (private keys, passwords, tokens, webhook secrets,
|
||||
* signature header values, env-var values) in $context.
|
||||
*
|
||||
* @param string $event Dot-namespaced event name, e.g. `api.private_key.created`.
|
||||
* @param array<string, mixed> $context Identifiers + outcome details.
|
||||
* @param string $level Log level: info | warning | error.
|
||||
*/
|
||||
function auditLog(string $event, array $context = [], string $level = 'info'): void
|
||||
{
|
||||
try {
|
||||
$request = app()->bound('request') ? request() : null;
|
||||
$user = auth()->check() ? auth()->user() : null;
|
||||
$token = $user?->currentAccessToken();
|
||||
|
||||
$base = [
|
||||
'event' => $event,
|
||||
'ip' => $request?->ip(),
|
||||
'ua' => substr((string) $request?->userAgent(), 0, 200),
|
||||
'user_id' => $user?->id,
|
||||
'user_email' => $user?->email,
|
||||
'team_id' => $token ? data_get($token, 'team_id') : null,
|
||||
'token_id' => $token?->id ?? null,
|
||||
'token_name' => $token?->name ?? null,
|
||||
'method' => $request?->method(),
|
||||
'path' => $request?->path(),
|
||||
];
|
||||
|
||||
$payload = array_merge($base, $context);
|
||||
|
||||
Log::channel('audit')->{$level}($event, $payload);
|
||||
} catch (Throwable $e) {
|
||||
// Audit logging must never break the request path.
|
||||
try {
|
||||
Log::warning('auditLog failed: '.$e->getMessage(), ['event' => $event]);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('auditLogWebhookFailure')) {
|
||||
/**
|
||||
* Record a webhook signature/auth verification failure to the `audit` channel.
|
||||
*/
|
||||
function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void
|
||||
{
|
||||
try {
|
||||
$request = app()->bound('request') ? request() : null;
|
||||
|
||||
$event = "webhook.{$provider}.signature_failed";
|
||||
|
||||
$base = [
|
||||
'event' => $event,
|
||||
'reason' => $reason,
|
||||
'ip' => $request?->ip(),
|
||||
'ua' => substr((string) $request?->userAgent(), 0, 200),
|
||||
'method' => $request?->method(),
|
||||
'path' => $request?->path(),
|
||||
'event_header' => $request?->header('X-GitHub-Event')
|
||||
?? $request?->header('X-Gitlab-Event')
|
||||
?? $request?->header('X-Gitea-Event')
|
||||
?? $request?->header('X-Event-Key'),
|
||||
];
|
||||
|
||||
Log::channel('audit')->warning($event, array_merge($base, $context));
|
||||
} catch (Throwable $e) {
|
||||
try {
|
||||
Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,26 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
|
||||
const REDACTED = '<REDACTED>';
|
||||
const DATABASE_TYPES = ['postgresql', 'redis', 'mongodb', 'mysql', 'mariadb', 'keydb', 'dragonfly', 'clickhouse'];
|
||||
const STANDALONE_DATABASE_MODELS = [
|
||||
'postgresql' => StandalonePostgresql::class,
|
||||
'redis' => StandaloneRedis::class,
|
||||
'mongodb' => StandaloneMongodb::class,
|
||||
'mysql' => StandaloneMysql::class,
|
||||
'mariadb' => StandaloneMariadb::class,
|
||||
'keydb' => StandaloneKeydb::class,
|
||||
'dragonfly' => StandaloneDragonfly::class,
|
||||
'clickhouse' => StandaloneClickhouse::class,
|
||||
];
|
||||
const VALID_CRON_STRINGS = [
|
||||
'every_minute' => '* * * * *',
|
||||
'hourly' => '0 * * * *',
|
||||
@@ -16,6 +35,9 @@ const VALID_CRON_STRINGS = [
|
||||
'@yearly' => '0 0 1 1 *',
|
||||
];
|
||||
const RESTART_MODE = 'unless-stopped';
|
||||
const DEFAULT_STOP_GRACE_PERIOD_SECONDS = 30;
|
||||
const MIN_STOP_GRACE_PERIOD_SECONDS = 1;
|
||||
const MAX_STOP_GRACE_PERIOD_SECONDS = 3600;
|
||||
|
||||
const DATABASE_DOCKER_IMAGES = [
|
||||
'bitnami/mariadb',
|
||||
@@ -81,4 +103,4 @@ const NEEDS_TO_DISABLE_GZIP = [
|
||||
const NEEDS_TO_DISABLE_STRIPPREFIX = [
|
||||
'appwrite' => ['appwrite', 'appwrite-console', 'appwrite-realtime'],
|
||||
];
|
||||
const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment'];
|
||||
const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment', 'server'];
|
||||
@@ -3,6 +3,7 @@
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
@@ -12,18 +13,18 @@ use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\SwarmDocker;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
function create_standalone_postgresql($environmentId, $destinationUuid, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
|
||||
function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destinationUuid)->firstOrFail();
|
||||
$database = new StandalonePostgresql;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->uuid = new_public_id();
|
||||
$database->name = 'postgresql-database-'.$database->uuid;
|
||||
$database->image = $databaseImage;
|
||||
$database->postgres_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->postgres_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environmentId;
|
||||
$database->destination_id = $destination->id;
|
||||
$database->destination_type = $destination->getMorphClass();
|
||||
@@ -35,14 +36,13 @@ function create_standalone_postgresql($environmentId, $destinationUuid, ?array $
|
||||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_redis($environment_id, $destination_uuid, ?array $otherData = null): StandaloneRedis
|
||||
function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneRedis
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneRedis;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->uuid = new_public_id();
|
||||
$database->name = 'redis-database-'.$database->uuid;
|
||||
|
||||
$redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$redis_password = Str::password(length: 64, symbols: false);
|
||||
if ($otherData && isset($otherData['redis_password'])) {
|
||||
$redis_password = $otherData['redis_password'];
|
||||
unset($otherData['redis_password']);
|
||||
@@ -75,13 +75,12 @@ function create_standalone_redis($environment_id, $destination_uuid, ?array $oth
|
||||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_mongodb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMongodb
|
||||
function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMongodb
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneMongodb;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->uuid = new_public_id();
|
||||
$database->name = 'mongodb-database-'.$database->uuid;
|
||||
$database->mongo_initdb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->mongo_initdb_root_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environment_id;
|
||||
$database->destination_id = $destination->id;
|
||||
$database->destination_type = $destination->getMorphClass();
|
||||
@@ -93,14 +92,13 @@ function create_standalone_mongodb($environment_id, $destination_uuid, ?array $o
|
||||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_mysql($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMysql
|
||||
function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMysql
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneMysql;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->uuid = new_public_id();
|
||||
$database->name = 'mysql-database-'.$database->uuid;
|
||||
$database->mysql_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->mysql_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->mysql_root_password = Str::password(length: 64, symbols: false);
|
||||
$database->mysql_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environment_id;
|
||||
$database->destination_id = $destination->id;
|
||||
$database->destination_type = $destination->getMorphClass();
|
||||
@@ -112,14 +110,13 @@ function create_standalone_mysql($environment_id, $destination_uuid, ?array $oth
|
||||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_mariadb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMariadb
|
||||
function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMariadb
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneMariadb;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->uuid = new_public_id();
|
||||
$database->name = 'mariadb-database-'.$database->uuid;
|
||||
$database->mariadb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->mariadb_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->mariadb_root_password = Str::password(length: 64, symbols: false);
|
||||
$database->mariadb_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environment_id;
|
||||
$database->destination_id = $destination->id;
|
||||
$database->destination_type = $destination->getMorphClass();
|
||||
@@ -131,13 +128,12 @@ function create_standalone_mariadb($environment_id, $destination_uuid, ?array $o
|
||||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_keydb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneKeydb
|
||||
function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneKeydb
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneKeydb;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->uuid = new_public_id();
|
||||
$database->name = 'keydb-database-'.$database->uuid;
|
||||
$database->keydb_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->keydb_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environment_id;
|
||||
$database->destination_id = $destination->id;
|
||||
$database->destination_type = $destination->getMorphClass();
|
||||
@@ -149,13 +145,12 @@ function create_standalone_keydb($environment_id, $destination_uuid, ?array $oth
|
||||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_dragonfly($environment_id, $destination_uuid, ?array $otherData = null): StandaloneDragonfly
|
||||
function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneDragonfly
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneDragonfly;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->uuid = new_public_id();
|
||||
$database->name = 'dragonfly-database-'.$database->uuid;
|
||||
$database->dragonfly_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->dragonfly_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environment_id;
|
||||
$database->destination_id = $destination->id;
|
||||
$database->destination_type = $destination->getMorphClass();
|
||||
@@ -167,13 +162,12 @@ function create_standalone_dragonfly($environment_id, $destination_uuid, ?array
|
||||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_clickhouse($environment_id, $destination_uuid, ?array $otherData = null): StandaloneClickhouse
|
||||
function create_standalone_clickhouse($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneClickhouse
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneClickhouse;
|
||||
$database->uuid = (new Cuid2);
|
||||
$database->uuid = new_public_id();
|
||||
$database->name = 'clickhouse-database-'.$database->uuid;
|
||||
$database->clickhouse_admin_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
$database->clickhouse_admin_password = Str::password(length: 64, symbols: false);
|
||||
$database->environment_id = $environment_id;
|
||||
$database->destination_id = $destination->id;
|
||||
$database->destination_type = $destination->getMorphClass();
|
||||
@@ -279,7 +273,7 @@ function removeOldBackups($backup): void
|
||||
->whereNull('s3_uploaded')
|
||||
->delete();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
@@ -345,7 +339,7 @@ function deleteOldBackupsLocally($backup): Collection
|
||||
$processedBackups = collect();
|
||||
|
||||
$server = null;
|
||||
if ($backup->database_type === \App\Models\ServiceDatabase::class) {
|
||||
if ($backup->database_type === ServiceDatabase::class) {
|
||||
$server = $backup->database->service->server;
|
||||
} else {
|
||||
$server = $backup->database->destination->server;
|
||||
|
||||
@@ -9,7 +9,6 @@ use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection
|
||||
{
|
||||
@@ -73,6 +72,36 @@ function getCurrentServiceContainerStatus(Server $server, int $id): Collection
|
||||
return $containers;
|
||||
}
|
||||
|
||||
function getCurrentDatabaseContainerStatus(Server $server, int $id): Collection
|
||||
{
|
||||
$containers = collect([]);
|
||||
if (! $server->isSwarm()) {
|
||||
$containers = instant_remote_process(["docker ps -a --filter='label=coolify.databaseId={$id}' --format '{{json .}}' "], $server);
|
||||
$containers = format_docker_command_output_to_json($containers);
|
||||
|
||||
return $containers->filter();
|
||||
}
|
||||
|
||||
return $containers;
|
||||
}
|
||||
|
||||
function getCurrentServiceSubContainerStatus(Server $server, int $id, string $name): Collection
|
||||
{
|
||||
return filterServiceSubContainersByName(getCurrentServiceContainerStatus($server, $id), $name);
|
||||
}
|
||||
|
||||
function filterServiceSubContainersByName(Collection $containers, string $name): Collection
|
||||
{
|
||||
return $containers->filter(function ($container) use ($name) {
|
||||
$labels = data_get($container, 'Labels', []);
|
||||
if (is_string($labels)) {
|
||||
$labels = format_docker_labels_to_json($labels);
|
||||
}
|
||||
|
||||
return collect($labels)->get('coolify.name') === $name;
|
||||
})->values();
|
||||
}
|
||||
|
||||
function format_docker_command_output_to_json($rawOutput): Collection
|
||||
{
|
||||
$outputLines = explode(PHP_EOL, $rawOutput);
|
||||
@@ -86,7 +115,7 @@ function format_docker_command_output_to_json($rawOutput): Collection
|
||||
return $outputLines
|
||||
->reject(fn ($line) => empty($line))
|
||||
->map(fn ($outputLine) => json_decode($outputLine, true, flags: JSON_THROW_ON_ERROR));
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
return collect([]);
|
||||
}
|
||||
}
|
||||
@@ -123,7 +152,7 @@ function format_docker_envs_to_json($rawOutput)
|
||||
|
||||
return [$env[0] => $env[1]];
|
||||
});
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
return collect([]);
|
||||
}
|
||||
}
|
||||
@@ -137,6 +166,11 @@ function checkMinimumDockerEngineVersion($dockerVersion)
|
||||
|
||||
return $dockerVersion;
|
||||
}
|
||||
function escapeShellValue(string $value): string
|
||||
{
|
||||
return "'".str_replace("'", "'\\''", $value)."'";
|
||||
}
|
||||
|
||||
function executeInDocker(string $containerId, string $command)
|
||||
{
|
||||
$escapedCommand = str_replace("'", "'\\''", $command);
|
||||
@@ -144,13 +178,18 @@ function executeInDocker(string $containerId, string $command)
|
||||
return "docker exec {$containerId} bash -c '{$escapedCommand}'";
|
||||
}
|
||||
|
||||
function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false)
|
||||
function buildContainerStatusCommand(Server $server, string $container_id): string
|
||||
{
|
||||
if ($server->isSwarm()) {
|
||||
$container = instant_remote_process(["docker service ls --filter 'name={$container_id}' --format '{{json .}}' "], $server, $throwError);
|
||||
} else {
|
||||
$container = instant_remote_process(["docker inspect --format '{{json .}}' {$container_id}"], $server, $throwError);
|
||||
return 'docker service ls --filter '.escapeshellarg("name={$container_id}")." --format '{{json .}}' ";
|
||||
}
|
||||
|
||||
return "docker inspect --format '{{json .}}' ".escapeshellarg($container_id);
|
||||
}
|
||||
|
||||
function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false)
|
||||
{
|
||||
$container = instant_remote_process([buildContainerStatusCommand($server, $container_id)], $server, $throwError);
|
||||
if (! $container) {
|
||||
return 'exited';
|
||||
}
|
||||
@@ -250,12 +289,12 @@ function defaultLabels($id, $name, string $projectName, string $resourceName, st
|
||||
|
||||
function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
|
||||
{
|
||||
if ($resource->getMorphClass() === \App\Models\ServiceApplication::class) {
|
||||
if ($resource->getMorphClass() === ServiceApplication::class) {
|
||||
$uuid = data_get($resource, 'uuid');
|
||||
$server = data_get($resource, 'service.server');
|
||||
$environment_variables = data_get($resource, 'service.environment_variables');
|
||||
$type = $resource->serviceType();
|
||||
} elseif ($resource->getMorphClass() === \App\Models\Application::class) {
|
||||
} elseif ($resource->getMorphClass() === Application::class) {
|
||||
$uuid = data_get($resource, 'uuid');
|
||||
$server = data_get($resource, 'destination.server');
|
||||
$environment_variables = data_get($resource, 'environment_variables');
|
||||
@@ -454,7 +493,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
|
||||
foreach ($domains as $loop => $domain) {
|
||||
try {
|
||||
if ($generate_unique_uuid) {
|
||||
$uuid = new Cuid2;
|
||||
$uuid = new_public_id();
|
||||
}
|
||||
|
||||
$url = Url::fromString($domain);
|
||||
@@ -636,7 +675,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -995,6 +1034,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
|
||||
'--ulimit',
|
||||
'--device',
|
||||
'--shm-size',
|
||||
'--dns',
|
||||
]);
|
||||
$mapping = collect([
|
||||
'--cap-add' => 'cap_add',
|
||||
@@ -1008,6 +1048,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
|
||||
'--ip' => 'ip',
|
||||
'--ip6' => 'ip6',
|
||||
'--shm-size' => 'shm_size',
|
||||
'--dns' => 'dns',
|
||||
'--gpus' => 'gpus',
|
||||
'--hostname' => 'hostname',
|
||||
'--entrypoint' => 'entrypoint',
|
||||
@@ -1214,7 +1255,7 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
|
||||
$server = Server::ownedByCurrentTeam()->find($server_id);
|
||||
try {
|
||||
if (! $server) {
|
||||
throw new \Exception('Server not found');
|
||||
throw new Exception('Server not found');
|
||||
}
|
||||
$yaml_compose = Yaml::parse($compose);
|
||||
|
||||
@@ -1230,7 +1271,7 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
|
||||
], $server);
|
||||
|
||||
return 'OK';
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
return $e->getMessage();
|
||||
} finally {
|
||||
if (filled($server)) {
|
||||
@@ -1241,18 +1282,38 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
|
||||
}
|
||||
}
|
||||
|
||||
function getContainerLogs(Server $server, string $container_id, int $lines = 100): string
|
||||
function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int
|
||||
{
|
||||
if ($server->isSwarm()) {
|
||||
$output = instant_remote_process([
|
||||
"docker service logs -n {$lines} {$container_id} 2>&1",
|
||||
], $server);
|
||||
} else {
|
||||
$output = instant_remote_process([
|
||||
"docker logs -n {$lines} {$container_id} 2>&1",
|
||||
], $server);
|
||||
$lines = filter_var($lines, FILTER_VALIDATE_INT);
|
||||
if ($lines === false || $lines <= 0) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return min($lines, $max);
|
||||
}
|
||||
|
||||
function parseLogTimestampFlag(mixed $showTimestamps): bool
|
||||
{
|
||||
return filter_var($showTimestamps, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
|
||||
}
|
||||
|
||||
function buildContainerLogsCommand(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string
|
||||
{
|
||||
$command = "docker logs -n {$lines}";
|
||||
if ($server->isSwarm()) {
|
||||
$command = "docker service logs -n {$lines}";
|
||||
}
|
||||
|
||||
if ($showTimestamps) {
|
||||
$command .= ' --timestamps';
|
||||
}
|
||||
|
||||
return "{$command} ".escapeshellarg($container_id).' 2>&1';
|
||||
}
|
||||
|
||||
function getContainerLogs(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string
|
||||
{
|
||||
$output = instant_remote_process([buildContainerLogsCommand($server, $container_id, $lines, $showTimestamps)], $server);
|
||||
$output = removeAnsiColors($output);
|
||||
|
||||
return $output;
|
||||
@@ -1346,10 +1407,10 @@ function escapeBashDoubleQuoted(?string $value): string
|
||||
* Generate Docker build arguments from environment variables collection
|
||||
* Returns only keys (no values) since values are sourced from environment via export
|
||||
*
|
||||
* @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
|
||||
* @return \Illuminate\Support\Collection Collection of formatted --build-arg strings (keys only)
|
||||
* @param Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
|
||||
* @return Collection Collection of formatted --build-arg strings (keys only)
|
||||
*/
|
||||
function generateDockerBuildArgs($variables): \Illuminate\Support\Collection
|
||||
function generateDockerBuildArgs($variables): Collection
|
||||
{
|
||||
$variables = collect($variables);
|
||||
|
||||
@@ -1357,14 +1418,14 @@ function generateDockerBuildArgs($variables): \Illuminate\Support\Collection
|
||||
$key = is_array($var) ? data_get($var, 'key') : $var->key;
|
||||
|
||||
// Only return the key - Docker will get the value from the environment
|
||||
return "--build-arg {$key}";
|
||||
return '--build-arg '.escapeshellarg((string) $key);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Docker environment flags from environment variables collection
|
||||
*
|
||||
* @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
|
||||
* @param Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
|
||||
* @return string Space-separated environment flags
|
||||
*/
|
||||
function generateDockerEnvFlags($variables): string
|
||||
|
||||
@@ -4,6 +4,54 @@ use App\Models\Application;
|
||||
use App\Models\ServiceApplication;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
function isValidDomainUrl(string $url): bool
|
||||
{
|
||||
$components = parse_url($url);
|
||||
|
||||
if ($components === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$scheme = $components['scheme'] ?? '';
|
||||
$host = $components['host'] ?? '';
|
||||
|
||||
if (! in_array(strtolower($scheme), ['http', 'https'], true) || $host === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$urlToValidate = $scheme.'://';
|
||||
|
||||
if (isset($components['user'])) {
|
||||
$urlToValidate .= $components['user'];
|
||||
|
||||
if (isset($components['pass'])) {
|
||||
$urlToValidate .= ':'.$components['pass'];
|
||||
}
|
||||
|
||||
$urlToValidate .= '@';
|
||||
}
|
||||
|
||||
$urlToValidate .= str_replace('_', '-', $host);
|
||||
|
||||
if (isset($components['port'])) {
|
||||
$urlToValidate .= ':'.$components['port'];
|
||||
}
|
||||
|
||||
if (isset($components['path'])) {
|
||||
$urlToValidate .= $components['path'];
|
||||
}
|
||||
|
||||
if (isset($components['query'])) {
|
||||
$urlToValidate .= '?'.$components['query'];
|
||||
}
|
||||
|
||||
if (isset($components['fragment'])) {
|
||||
$urlToValidate .= '#'.$components['fragment'];
|
||||
}
|
||||
|
||||
return filter_var($urlToValidate, FILTER_VALIDATE_URL) !== false;
|
||||
}
|
||||
|
||||
function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null)
|
||||
{
|
||||
$conflicts = [];
|
||||
|
||||
+242
-17
@@ -2,8 +2,10 @@
|
||||
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Lcobucci\JWT\Encoding\ChainedFormatter;
|
||||
@@ -12,15 +14,150 @@ use Lcobucci\JWT\Signer\Key\InMemory;
|
||||
use Lcobucci\JWT\Signer\Rsa\Sha256;
|
||||
use Lcobucci\JWT\Token\Builder;
|
||||
|
||||
function generateGithubToken(GithubApp $source, string $type)
|
||||
/**
|
||||
* Extract and normalize the hostname from a GitHub URL.
|
||||
*
|
||||
* @param string|null $url The URL to parse
|
||||
* @return string|null The lowercase hostname, or null if the URL is blank or has no parseable host
|
||||
*/
|
||||
function githubUrlHost(?string $url): ?string
|
||||
{
|
||||
$response = Http::get("{$source->api_url}/zen");
|
||||
if (blank($url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
|
||||
if (! is_string($host) || blank($host)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strtolower($host);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the scheme://host[:port] origin for a GitHub URL.
|
||||
*
|
||||
* This helper fails explicitly for blank, scheme-less, or malformed input when
|
||||
* githubUrlHost() cannot parse a host, because returning the original input
|
||||
* would not be a valid origin. Callers should pass already-validated URLs.
|
||||
*
|
||||
* @param string $url The URL to derive the origin from
|
||||
* @return string The normalized origin
|
||||
*
|
||||
* @throws InvalidArgumentException When the URL does not contain a parseable scheme and host
|
||||
*/
|
||||
function githubUrlOrigin(string $url): string
|
||||
{
|
||||
$scheme = parse_url($url, PHP_URL_SCHEME);
|
||||
$host = githubUrlHost($url);
|
||||
$port = parse_url($url, PHP_URL_PORT);
|
||||
|
||||
if (! is_string($scheme) || blank($scheme) || ! $host) {
|
||||
throw new InvalidArgumentException('GitHub URL must include a valid scheme and host.');
|
||||
}
|
||||
|
||||
return $scheme.'://'.$host.($port ? ":{$port}" : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the URL points at github.com.
|
||||
*
|
||||
* @param string|null $htmlUrl The GitHub HTML URL to check
|
||||
*/
|
||||
function isGithubDotComHost(?string $htmlUrl): bool
|
||||
{
|
||||
return githubUrlHost($htmlUrl) === 'github.com';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the URL points at a *.ghe.com GitHub Enterprise Cloud host.
|
||||
*
|
||||
* @param string|null $htmlUrl The GitHub HTML URL to check
|
||||
*/
|
||||
function isGheDotComHost(?string $htmlUrl): bool
|
||||
{
|
||||
$host = githubUrlHost($htmlUrl);
|
||||
|
||||
return is_string($host)
|
||||
&& Str::endsWith($host, '.ghe.com')
|
||||
&& ! Str::startsWith($host, 'api.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the URL belongs to GitHub's cloud family (github.com or *.ghe.com).
|
||||
*
|
||||
* @param string|null $htmlUrl The GitHub HTML URL to check
|
||||
*/
|
||||
function isGithubCloudFamilyHost(?string $htmlUrl): bool
|
||||
{
|
||||
return isGithubDotComHost($htmlUrl) || isGheDotComHost($htmlUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the URL belongs to a self-hosted GitHub Enterprise Server.
|
||||
*
|
||||
* @param string|null $htmlUrl The GitHub HTML URL to check
|
||||
*/
|
||||
function isGithubEnterpriseServerHost(?string $htmlUrl): bool
|
||||
{
|
||||
return filled($htmlUrl) && ! isGithubCloudFamilyHost($htmlUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the GitHub REST API base URL from a GitHub HTML URL.
|
||||
*
|
||||
* @param string $htmlUrl The GitHub HTML URL
|
||||
* @return string The API base URL (api.github.com, api.<host> for *.ghe.com, or <origin>/api/v3 for GHES)
|
||||
*/
|
||||
function githubApiUrlFromHtmlUrl(string $htmlUrl): string
|
||||
{
|
||||
if (isGithubDotComHost($htmlUrl)) {
|
||||
return 'https://api.github.com';
|
||||
}
|
||||
|
||||
if (isGheDotComHost($htmlUrl)) {
|
||||
return 'https://api.'.githubUrlHost($htmlUrl);
|
||||
}
|
||||
|
||||
return githubUrlOrigin($htmlUrl).'/api/v3';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a GitHub organization slug by trimming surrounding slashes and whitespace.
|
||||
*
|
||||
* @param string|null $organization The raw organization value
|
||||
* @return string|null The trimmed organization, or null when blank
|
||||
*/
|
||||
function normalizeGithubOrganization(?string $organization): ?string
|
||||
{
|
||||
if (blank($organization)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim((string) $organization, "/ \t\n\r\0\x0B");
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-encode a single GitHub path segment.
|
||||
*
|
||||
* @param string $segment The raw path segment
|
||||
* @return string The raw-URL-encoded segment
|
||||
*/
|
||||
function encodeGithubPathSegment(string $segment): string
|
||||
{
|
||||
return rawurlencode($segment);
|
||||
}
|
||||
|
||||
function assertGithubClockInSync(string $apiUrl): void
|
||||
{
|
||||
$response = Http::get("{$apiUrl}/zen");
|
||||
$serverTime = CarbonImmutable::now()->setTimezone('UTC');
|
||||
$githubTime = Carbon::parse($response->header('date'));
|
||||
$timeDiff = abs($serverTime->diffInSeconds($githubTime));
|
||||
|
||||
if ($timeDiff > 50) {
|
||||
throw new \Exception(
|
||||
throw new Exception(
|
||||
'System time is out of sync with GitHub API time:<br>'.
|
||||
'- System time: '.$serverTime->format('Y-m-d H:i:s').' UTC<br>'.
|
||||
'- GitHub time: '.$githubTime->format('Y-m-d H:i:s').' UTC<br>'.
|
||||
@@ -28,6 +165,11 @@ function generateGithubToken(GithubApp $source, string $type)
|
||||
'Please synchronize your system clock.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function generateGithubToken(GithubApp $source, string $type)
|
||||
{
|
||||
assertGithubClockInSync($source->api_url);
|
||||
|
||||
$signingKey = InMemory::plainText($source->privateKey->private_key);
|
||||
$algorithm = new Sha256;
|
||||
@@ -60,7 +202,7 @@ function generateGithubToken(GithubApp $source, string $type)
|
||||
|
||||
return $response->json()['token'];
|
||||
})(),
|
||||
default => throw new \InvalidArgumentException("Unsupported token type: {$type}")
|
||||
default => throw new InvalidArgumentException("Unsupported token type: {$type}")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,11 +219,11 @@ function generateGithubJwt(GithubApp $source)
|
||||
function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true)
|
||||
{
|
||||
if (is_null($source)) {
|
||||
throw new \Exception('Source is required for API calls');
|
||||
throw new Exception('Source is required for API calls');
|
||||
}
|
||||
|
||||
if ($source->getMorphClass() !== GithubApp::class) {
|
||||
throw new \InvalidArgumentException("Unsupported source type: {$source->getMorphClass()}");
|
||||
throw new InvalidArgumentException("Unsupported source type: {$source->getMorphClass()}");
|
||||
}
|
||||
|
||||
if ($source->is_public) {
|
||||
@@ -100,7 +242,7 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m
|
||||
$errorMessage = data_get($response->json(), 'message', 'no error message found');
|
||||
$remainingCalls = $response->header('X-RateLimit-Remaining', '0');
|
||||
|
||||
throw new \Exception(
|
||||
throw new Exception(
|
||||
'GitHub API call failed:<br>'.
|
||||
"Error: {$errorMessage}<br>".
|
||||
'Rate Limit Status:<br>'.
|
||||
@@ -116,21 +258,106 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m
|
||||
];
|
||||
}
|
||||
|
||||
function getInstallationPath(GithubApp $source)
|
||||
function generateGithubAppJwt(string $privateKey, string|int $appId): string
|
||||
{
|
||||
$github = GithubApp::where('uuid', $source->uuid)->first();
|
||||
$name = str(Str::kebab($github->name));
|
||||
$installation_path = $github->html_url === 'https://github.com' ? 'apps' : 'github-apps';
|
||||
$algorithm = new Sha256;
|
||||
$tokenBuilder = (new Builder(new JoseEncoder, ChainedFormatter::default()));
|
||||
$now = CarbonImmutable::now()->setTimezone('UTC');
|
||||
$now = $now->setTime($now->format('H'), $now->format('i'), $now->format('s'));
|
||||
|
||||
return "$github->html_url/$installation_path/$name/installations/new";
|
||||
return $tokenBuilder
|
||||
->issuedBy((string) $appId)
|
||||
->issuedAt($now->modify('-1 minute'))
|
||||
->expiresAt($now->modify('+8 minutes'))
|
||||
->getToken($algorithm, InMemory::plainText($privateKey))
|
||||
->toString();
|
||||
}
|
||||
|
||||
function syncGithubAppName(GithubApp $source, bool $throw = false): ?string
|
||||
{
|
||||
try {
|
||||
if (blank($source->app_id) || blank($source->private_key_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$privateKey = $source->privateKey ?: PrivateKey::find($source->private_key_id);
|
||||
|
||||
if (! $privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
assertGithubClockInSync($source->api_url);
|
||||
|
||||
$jwt = generateGithubAppJwt($privateKey->private_key, $source->app_id);
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version' => '2022-11-28',
|
||||
'Authorization' => "Bearer {$jwt}",
|
||||
])->get("{$source->api_url}/app");
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException(data_get($response->json(), 'message', 'Failed to fetch GitHub App information.'));
|
||||
}
|
||||
|
||||
$appSlug = data_get($response->json(), 'slug');
|
||||
|
||||
if (blank($appSlug)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$source->name = $appSlug;
|
||||
|
||||
if ($source->exists) {
|
||||
$source->save();
|
||||
}
|
||||
|
||||
$privateKey->name = "github-app-{$appSlug}";
|
||||
$privateKey->save();
|
||||
|
||||
return $appSlug;
|
||||
} catch (Throwable $e) {
|
||||
if ($throw) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getInstallationPath(GithubApp $source): string
|
||||
{
|
||||
$name = encodeGithubPathSegment(Str::kebab($source->name));
|
||||
$state = Str::random(64);
|
||||
$organization = normalizeGithubOrganization($source->organization);
|
||||
|
||||
if (isGithubEnterpriseServerHost($source->html_url)) {
|
||||
$path = "github-apps/{$name}";
|
||||
} elseif (isGheDotComHost($source->html_url) && filled($organization)) {
|
||||
$path = 'apps/'.encodeGithubPathSegment($organization)."/{$name}";
|
||||
} else {
|
||||
$path = "apps/{$name}";
|
||||
}
|
||||
|
||||
Cache::put('github-app-setup-state:'.hash('sha256', $state), [
|
||||
'action' => 'install',
|
||||
'github_app_id' => $source->id,
|
||||
'team_id' => $source->team_id,
|
||||
], now()->addMinutes(60));
|
||||
|
||||
return rtrim($source->html_url, '/')."/{$path}/installations/new?".http_build_query(['state' => $state]);
|
||||
}
|
||||
|
||||
function getPermissionsPath(GithubApp $source)
|
||||
{
|
||||
$github = GithubApp::where('uuid', $source->uuid)->first();
|
||||
$name = str(Str::kebab($github->name));
|
||||
$name = encodeGithubPathSegment(Str::kebab($source->name));
|
||||
$organization = normalizeGithubOrganization($source->organization);
|
||||
|
||||
return "$github->html_url/settings/apps/$name/permissions";
|
||||
if (filled($organization)) {
|
||||
return rtrim($source->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}/permissions";
|
||||
}
|
||||
|
||||
return rtrim($source->html_url, '/')."/settings/apps/{$name}/permissions";
|
||||
}
|
||||
|
||||
function loadRepositoryByPage(GithubApp $source, string $token, int $page)
|
||||
@@ -182,7 +409,6 @@ function getGithubCommitRangeFiles(?GithubApp $source, string $owner, string $re
|
||||
|
||||
return $files->pluck('filename')->filter()->values()->toArray();
|
||||
} catch (Exception $e) {
|
||||
ray('Error fetching GitHub commit range files: '.$e->getMessage());
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -208,7 +434,6 @@ function getGithubPullRequestFiles(?GithubApp $source, string $owner, string $re
|
||||
|
||||
return $files->pluck('filename')->filter()->values()->toArray();
|
||||
} catch (Exception $e) {
|
||||
ray('Error fetching GitHub PR files: '.$e->getMessage());
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ function send_internal_notification(string $message): void
|
||||
try {
|
||||
$team = Team::find(0);
|
||||
$team?->notify(new GeneralNotification($message));
|
||||
} catch (\Throwable $e) {
|
||||
ray($e->getMessage());
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+114
-88
@@ -14,7 +14,6 @@ use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
/**
|
||||
* Validates a Docker Compose YAML string for command injection vulnerabilities.
|
||||
@@ -22,25 +21,25 @@ use Visus\Cuid2\Cuid2;
|
||||
*
|
||||
* @param string $composeYaml The raw Docker Compose YAML content
|
||||
*
|
||||
* @throws \Exception If the compose file contains command injection attempts
|
||||
* @throws Exception If the compose file contains command injection attempts
|
||||
*/
|
||||
function validateDockerComposeForInjection(string $composeYaml): void
|
||||
{
|
||||
try {
|
||||
$parsed = Yaml::parse($composeYaml);
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception('Invalid YAML format: '.$e->getMessage(), 0, $e);
|
||||
} catch (Exception $e) {
|
||||
throw new Exception('Invalid YAML format: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
if (! is_array($parsed) || ! isset($parsed['services']) || ! is_array($parsed['services'])) {
|
||||
throw new \Exception('Docker Compose file must contain a "services" section');
|
||||
throw new Exception('Docker Compose file must contain a "services" section');
|
||||
}
|
||||
// Validate service names
|
||||
foreach ($parsed['services'] as $serviceName => $serviceConfig) {
|
||||
try {
|
||||
validateShellSafePath($serviceName, 'service name');
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception(
|
||||
} catch (Exception $e) {
|
||||
throw new Exception(
|
||||
'Invalid Docker Compose service name: '.$e->getMessage().
|
||||
' Service names must not contain shell metacharacters.',
|
||||
0,
|
||||
@@ -68,8 +67,8 @@ function validateDockerComposeForInjection(string $composeYaml): void
|
||||
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
|
||||
try {
|
||||
validateShellSafePath($source, 'volume source');
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception(
|
||||
} catch (Exception $e) {
|
||||
throw new Exception(
|
||||
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
|
||||
' Please use safe path names without shell metacharacters.',
|
||||
0,
|
||||
@@ -84,8 +83,8 @@ function validateDockerComposeForInjection(string $composeYaml): void
|
||||
if (is_string($target)) {
|
||||
try {
|
||||
validateShellSafePath($target, 'volume target');
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception(
|
||||
} catch (Exception $e) {
|
||||
throw new Exception(
|
||||
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
|
||||
' Please use safe path names without shell metacharacters.',
|
||||
0,
|
||||
@@ -105,7 +104,7 @@ function validateDockerComposeForInjection(string $composeYaml): void
|
||||
*
|
||||
* @param string $volumeString The volume string to validate
|
||||
*
|
||||
* @throws \Exception If the volume string contains command injection attempts
|
||||
* @throws Exception If the volume string contains command injection attempts
|
||||
*/
|
||||
function validateVolumeStringForInjection(string $volumeString): void
|
||||
{
|
||||
@@ -325,9 +324,9 @@ function parseDockerVolumeString(string $volumeString): array
|
||||
if (! $isSimpleEnvVar && ! $isEnvVarWithPath) {
|
||||
try {
|
||||
validateShellSafePath($sourceStr, 'volume source');
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
// Re-throw with more context about the volume string
|
||||
throw new \Exception(
|
||||
throw new Exception(
|
||||
'Invalid Docker volume definition: '.$e->getMessage().
|
||||
' Please use safe path names without shell metacharacters.'
|
||||
);
|
||||
@@ -343,8 +342,8 @@ function parseDockerVolumeString(string $volumeString): array
|
||||
// Still, defense in depth is important
|
||||
try {
|
||||
validateShellSafePath($targetStr, 'volume target');
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception(
|
||||
} catch (Exception $e) {
|
||||
throw new Exception(
|
||||
'Invalid Docker volume definition: '.$e->getMessage().
|
||||
' Please use safe path names without shell metacharacters.'
|
||||
);
|
||||
@@ -371,11 +370,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
$pullRequestId = $pull_request_id;
|
||||
$isPullRequest = $pullRequestId == 0 ? false : true;
|
||||
$server = data_get($resource, 'destination.server');
|
||||
$fileStorages = $resource->fileStorages();
|
||||
|
||||
try {
|
||||
$yaml = Yaml::parse($compose);
|
||||
} catch (\Exception) {
|
||||
} catch (Exception) {
|
||||
return collect([]);
|
||||
}
|
||||
$services = data_get($yaml, 'services', collect([]));
|
||||
@@ -409,8 +406,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
// Validate service name for command injection
|
||||
try {
|
||||
validateShellSafePath($serviceName, 'service name');
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception(
|
||||
} catch (Exception $e) {
|
||||
throw new Exception(
|
||||
'Invalid Docker Compose service name: '.$e->getMessage().
|
||||
' Service names must not contain shell metacharacters.'
|
||||
);
|
||||
@@ -465,7 +462,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
$fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version);
|
||||
}
|
||||
|
||||
if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
|
||||
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
|
||||
$path = $value->value();
|
||||
if ($path !== '/') {
|
||||
$fqdn = "$fqdn$path";
|
||||
@@ -504,6 +501,40 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Also populate docker_compose_domains for dockercompose apps from direct SERVICE_* declarations.
|
||||
if ($resource->build_pack === 'dockercompose' && ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_'))) {
|
||||
$parsed = parseServiceEnvironmentVariable($key->value());
|
||||
$normalizedServiceName = str($parsed['service_name'])->replace('-', '_')->replace('.', '_')->value();
|
||||
$serviceExists = false;
|
||||
foreach (array_keys($services) as $serviceNameKey) {
|
||||
if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedServiceName) {
|
||||
$serviceExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($serviceExists) {
|
||||
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]'));
|
||||
$domainExists = data_get($domains->get($normalizedServiceName), 'domain');
|
||||
if (is_null($domainExists)) {
|
||||
$serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value();
|
||||
$domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid");
|
||||
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
|
||||
$path = $value->value();
|
||||
if ($path !== '/') {
|
||||
$domainValue = "$domainValue$path";
|
||||
}
|
||||
}
|
||||
if ($parsed['port'] && is_numeric($parsed['port'])) {
|
||||
$domainValue = "$domainValue:{$parsed['port']}";
|
||||
}
|
||||
$domains->put($normalizedServiceName, ['domain' => $domainValue]);
|
||||
$resource->docker_compose_domains = $domains->toJson();
|
||||
$resource->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,7 +642,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
|
||||
// Only add domain if the service exists
|
||||
if ($serviceExists) {
|
||||
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
|
||||
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]'));
|
||||
$domainExists = data_get($domains->get($serviceName), 'domain');
|
||||
|
||||
// Update domain using URL with port if applicable
|
||||
@@ -704,14 +735,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
$source = $parsed['source'];
|
||||
$target = $parsed['target'];
|
||||
// Mode is available in $parsed['mode'] if needed
|
||||
$foundConfig = $fileStorages->whereMountPath($target)->first();
|
||||
$foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
|
||||
if (sourceIsLocal($source)) {
|
||||
$type = str('bind');
|
||||
if ($foundConfig) {
|
||||
$contentNotNull_temp = data_get($foundConfig, 'content');
|
||||
if ($contentNotNull_temp) {
|
||||
$content = $contentNotNull_temp;
|
||||
}
|
||||
$content = data_get($foundConfig, 'content');
|
||||
$isDirectory = data_get($foundConfig, 'is_directory');
|
||||
} else {
|
||||
// By default, we cannot determine if the bind is a directory or not, so we set it to directory
|
||||
@@ -738,8 +766,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
|
||||
try {
|
||||
validateShellSafePath($sourceValue, 'volume source');
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception(
|
||||
} catch (Exception $e) {
|
||||
throw new Exception(
|
||||
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
|
||||
' Please use safe path names without shell metacharacters.'
|
||||
);
|
||||
@@ -749,20 +777,17 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
if ($target !== null && ! empty($target->value())) {
|
||||
try {
|
||||
validateShellSafePath($target->value(), 'volume target');
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception(
|
||||
} catch (Exception $e) {
|
||||
throw new Exception(
|
||||
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
|
||||
' Please use safe path names without shell metacharacters.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$foundConfig = $fileStorages->whereMountPath($target)->first();
|
||||
$foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
|
||||
if ($foundConfig) {
|
||||
$contentNotNull_temp = data_get($foundConfig, 'content');
|
||||
if ($contentNotNull_temp) {
|
||||
$content = $contentNotNull_temp;
|
||||
}
|
||||
$content = data_get($foundConfig, 'content');
|
||||
$isDirectory = data_get($foundConfig, 'is_directory');
|
||||
} else {
|
||||
// if isDirectory is not set (or false) & content is also not set, we assume it is a directory
|
||||
@@ -789,7 +814,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
$mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
|
||||
}
|
||||
$source = replaceLocalSource($source, $mainDirectory);
|
||||
if ($isPullRequest) {
|
||||
$isPreviewSuffixEnabled = $foundConfig
|
||||
? (bool) data_get($foundConfig, 'is_preview_suffix_enabled', true)
|
||||
: true;
|
||||
if ($isPullRequest && $isPreviewSuffixEnabled) {
|
||||
$source = addPreviewDeploymentSuffix($source, $pull_request_id);
|
||||
}
|
||||
LocalFileVolume::updateOrCreate(
|
||||
@@ -987,16 +1015,17 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
}
|
||||
if ($key->value() === $parsedValue->value()) {
|
||||
// Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL})
|
||||
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
|
||||
$envVar = $resource->environment_variables()->firstOrCreate([
|
||||
// Ensure the variable exists in DB for .env generation and UI display
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
'key' => $key,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'is_preview' => false,
|
||||
]);
|
||||
// Add the variable to the environment using the saved DB value
|
||||
$environment[$key->value()] = $envVar->value;
|
||||
// Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time.
|
||||
// Do NOT replace with DB value: if user updates env var without re-parsing compose,
|
||||
// a stale resolved value in environment: would override the correct .env value.
|
||||
} else {
|
||||
if ($value->startsWith('$')) {
|
||||
$isRequired = false;
|
||||
@@ -1236,7 +1265,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
$schema = $url->getScheme();
|
||||
$portInt = $url->getPort();
|
||||
$port = $portInt !== null ? ':'.$portInt : '';
|
||||
$random = new Cuid2;
|
||||
$random = new_public_id();
|
||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
||||
$preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
|
||||
@@ -1315,19 +1344,19 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
}
|
||||
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
|
||||
$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;
|
||||
$uuid = $resource->uuid;
|
||||
$network = data_get($resource, 'destination.network');
|
||||
$labelUuid = $resource->uuid;
|
||||
$labelNetwork = data_get($resource, 'destination.network');
|
||||
if ($isPullRequest) {
|
||||
$uuid = "{$resource->uuid}-{$pullRequestId}";
|
||||
$labelUuid = "{$resource->uuid}-{$pullRequestId}";
|
||||
}
|
||||
if ($isPullRequest) {
|
||||
$network = "{$resource->destination->network}-{$pullRequestId}";
|
||||
$labelNetwork = "{$resource->destination->network}-{$pullRequestId}";
|
||||
}
|
||||
if ($shouldGenerateLabelsExactly) {
|
||||
switch ($server->proxyType()) {
|
||||
case ProxyTypes::TRAEFIK->value:
|
||||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
uuid: $labelUuid,
|
||||
domains: $fqdns,
|
||||
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
|
||||
serviceLabels: $serviceLabels,
|
||||
@@ -1339,8 +1368,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
break;
|
||||
case ProxyTypes::CADDY->value:
|
||||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
||||
network: $network,
|
||||
uuid: $uuid,
|
||||
network: $labelNetwork,
|
||||
uuid: $labelUuid,
|
||||
domains: $fqdns,
|
||||
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
|
||||
serviceLabels: $serviceLabels,
|
||||
@@ -1354,7 +1383,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
}
|
||||
} else {
|
||||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
uuid: $labelUuid,
|
||||
domains: $fqdns,
|
||||
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
|
||||
serviceLabels: $serviceLabels,
|
||||
@@ -1364,8 +1393,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
image: $image
|
||||
));
|
||||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
||||
network: $network,
|
||||
uuid: $uuid,
|
||||
network: $labelNetwork,
|
||||
uuid: $labelUuid,
|
||||
domains: $fqdns,
|
||||
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
|
||||
serviceLabels: $serviceLabels,
|
||||
@@ -1485,9 +1514,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
}
|
||||
}
|
||||
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception) {
|
||||
// If parsing fails, keep the original docker_compose_raw unchanged
|
||||
ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage());
|
||||
}
|
||||
|
||||
data_forget($resource, 'environment_variables');
|
||||
@@ -1515,7 +1543,7 @@ function serviceParser(Service $resource): Collection
|
||||
|
||||
try {
|
||||
$yaml = Yaml::parse($compose);
|
||||
} catch (\Exception) {
|
||||
} catch (Exception) {
|
||||
return collect([]);
|
||||
}
|
||||
$services = data_get($yaml, 'services', collect([]));
|
||||
@@ -1562,8 +1590,8 @@ function serviceParser(Service $resource): Collection
|
||||
// Validate service name for command injection
|
||||
try {
|
||||
validateShellSafePath($serviceName, 'service name');
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception(
|
||||
} catch (Exception $e) {
|
||||
throw new Exception(
|
||||
'Invalid Docker Compose service name: '.$e->getMessage().
|
||||
' Service names must not contain shell metacharacters.'
|
||||
);
|
||||
@@ -1589,20 +1617,25 @@ function serviceParser(Service $resource): Collection
|
||||
// Use image detection for non-migrated services
|
||||
$isDatabase = isDatabaseImage($image, $service);
|
||||
if ($isDatabase) {
|
||||
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
|
||||
if ($applicationFound) {
|
||||
$savedService = $applicationFound;
|
||||
$databaseFound = ServiceDatabase::where('name', $serviceName)->where('service_id', $resource->id)->first();
|
||||
if ($databaseFound) {
|
||||
$savedService = $databaseFound;
|
||||
} else {
|
||||
$savedService = ServiceDatabase::firstOrCreate([
|
||||
$savedService = ServiceDatabase::create([
|
||||
'name' => $serviceName,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$savedService = ServiceApplication::firstOrCreate([
|
||||
'name' => $serviceName,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
|
||||
if ($applicationFound) {
|
||||
$savedService = $applicationFound;
|
||||
} else {
|
||||
$savedService = ServiceApplication::create([
|
||||
'name' => $serviceName,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update image if it changed
|
||||
@@ -1768,7 +1801,7 @@ function serviceParser(Service $resource): Collection
|
||||
// Strip scheme for environment variable values
|
||||
$fqdnValueForEnv = str($fqdn)->after('://')->value();
|
||||
|
||||
if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
|
||||
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
|
||||
$path = $value->value();
|
||||
if ($path !== '/') {
|
||||
// Only add path if it's not already present (prevents duplication on subsequent parse() calls)
|
||||
@@ -2062,7 +2095,6 @@ function serviceParser(Service $resource): Collection
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
}
|
||||
$fileStorages = $savedService->fileStorages();
|
||||
if ($savedService->image !== $image) {
|
||||
$savedService->image = $image;
|
||||
$savedService->save();
|
||||
@@ -2082,14 +2114,11 @@ function serviceParser(Service $resource): Collection
|
||||
$source = $parsed['source'];
|
||||
$target = $parsed['target'];
|
||||
// Mode is available in $parsed['mode'] if needed
|
||||
$foundConfig = $fileStorages->whereMountPath($target)->first();
|
||||
$foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
|
||||
if (sourceIsLocal($source)) {
|
||||
$type = str('bind');
|
||||
if ($foundConfig) {
|
||||
$contentNotNull_temp = data_get($foundConfig, 'content');
|
||||
if ($contentNotNull_temp) {
|
||||
$content = $contentNotNull_temp;
|
||||
}
|
||||
$content = data_get($foundConfig, 'content');
|
||||
$isDirectory = data_get($foundConfig, 'is_directory');
|
||||
} else {
|
||||
// By default, we cannot determine if the bind is a directory or not, so we set it to directory
|
||||
@@ -2116,8 +2145,8 @@ function serviceParser(Service $resource): Collection
|
||||
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
|
||||
try {
|
||||
validateShellSafePath($sourceValue, 'volume source');
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception(
|
||||
} catch (Exception $e) {
|
||||
throw new Exception(
|
||||
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
|
||||
' Please use safe path names without shell metacharacters.'
|
||||
);
|
||||
@@ -2127,20 +2156,17 @@ function serviceParser(Service $resource): Collection
|
||||
if ($target !== null && ! empty($target->value())) {
|
||||
try {
|
||||
validateShellSafePath($target->value(), 'volume target');
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception(
|
||||
} catch (Exception $e) {
|
||||
throw new Exception(
|
||||
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
|
||||
' Please use safe path names without shell metacharacters.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$foundConfig = $fileStorages->whereMountPath($target)->first();
|
||||
$foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
|
||||
if ($foundConfig) {
|
||||
$contentNotNull_temp = data_get($foundConfig, 'content');
|
||||
if ($contentNotNull_temp) {
|
||||
$content = $contentNotNull_temp;
|
||||
}
|
||||
$content = data_get($foundConfig, 'content');
|
||||
$isDirectory = data_get($foundConfig, 'is_directory');
|
||||
} else {
|
||||
// if isDirectory is not set (or false) & content is also not set, we assume it is a directory
|
||||
@@ -2338,8 +2364,8 @@ function serviceParser(Service $resource): Collection
|
||||
}
|
||||
if ($key->value() === $parsedValue->value()) {
|
||||
// Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL})
|
||||
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
|
||||
$envVar = $resource->environment_variables()->firstOrCreate([
|
||||
// Ensure the variable exists in DB for .env generation and UI display
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
'key' => $key,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
@@ -2347,8 +2373,9 @@ function serviceParser(Service $resource): Collection
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$originalKey] ?? null,
|
||||
]);
|
||||
// Add the variable to the environment using the saved DB value
|
||||
$environment[$key->value()] = $envVar->value;
|
||||
// Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time.
|
||||
// Do NOT replace with DB value: if user updates env var without re-parsing compose,
|
||||
// a stale resolved value in environment: would override the correct .env value.
|
||||
} else {
|
||||
if ($value->startsWith('$')) {
|
||||
$isRequired = false;
|
||||
@@ -2736,9 +2763,8 @@ function serviceParser(Service $resource): Collection
|
||||
}
|
||||
}
|
||||
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
// If parsing fails, keep the original docker_compose_raw unchanged
|
||||
ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage());
|
||||
}
|
||||
|
||||
data_forget($resource, 'environment_variables');
|
||||
|
||||
+23
-14
@@ -4,6 +4,7 @@ use App\Actions\Proxy\SaveProxyConfiguration;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
@@ -109,18 +110,22 @@ function connectProxyToNetworks(Server $server)
|
||||
['networks' => $networks] = collectDockerNetworksByServer($server);
|
||||
if ($server->isSwarm()) {
|
||||
$commands = $networks->map(function ($network) {
|
||||
$safe = escapeshellarg($network);
|
||||
|
||||
return [
|
||||
"docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --driver overlay --attachable $network >/dev/null",
|
||||
"docker network connect $network coolify-proxy >/dev/null 2>&1 || true",
|
||||
"echo 'Successfully connected coolify-proxy to $network network.'",
|
||||
"docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --driver overlay --attachable {$safe} >/dev/null",
|
||||
"docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true",
|
||||
"echo 'Successfully connected coolify-proxy to {$safe} network.'",
|
||||
];
|
||||
});
|
||||
} else {
|
||||
$commands = $networks->map(function ($network) {
|
||||
$safe = escapeshellarg($network);
|
||||
|
||||
return [
|
||||
"docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --attachable $network >/dev/null",
|
||||
"docker network connect $network coolify-proxy >/dev/null 2>&1 || true",
|
||||
"echo 'Successfully connected coolify-proxy to $network network.'",
|
||||
"docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --attachable {$safe} >/dev/null",
|
||||
"docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true",
|
||||
"echo 'Successfully connected coolify-proxy to {$safe} network.'",
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -133,7 +138,7 @@ function connectProxyToNetworks(Server $server)
|
||||
* This must be called BEFORE docker compose up since the compose file declares networks as external.
|
||||
*
|
||||
* @param Server $server The server to ensure networks on
|
||||
* @return \Illuminate\Support\Collection Commands to create networks if they don't exist
|
||||
* @return Collection Commands to create networks if they don't exist
|
||||
*/
|
||||
function ensureProxyNetworksExist(Server $server)
|
||||
{
|
||||
@@ -141,16 +146,20 @@ function ensureProxyNetworksExist(Server $server)
|
||||
|
||||
if ($server->isSwarm()) {
|
||||
$commands = $networks->map(function ($network) {
|
||||
$safe = escapeshellarg($network);
|
||||
|
||||
return [
|
||||
"echo 'Ensuring network $network exists...'",
|
||||
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network",
|
||||
"echo 'Ensuring network {$safe} exists...'",
|
||||
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable {$safe}",
|
||||
];
|
||||
});
|
||||
} else {
|
||||
$commands = $networks->map(function ($network) {
|
||||
$safe = escapeshellarg($network);
|
||||
|
||||
return [
|
||||
"echo 'Ensuring network $network exists...'",
|
||||
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network",
|
||||
"echo 'Ensuring network {$safe} exists...'",
|
||||
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable {$safe}",
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -207,7 +216,7 @@ function extractCustomProxyCommands(Server $server, string $existing_config): ar
|
||||
$custom_commands[] = $command;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
// If we can't parse the config, return empty array
|
||||
// Silently fail to avoid breaking the proxy regeneration
|
||||
}
|
||||
@@ -428,7 +437,7 @@ function getExactTraefikVersionFromContainer(Server $server): ?string
|
||||
Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Could not detect exact version");
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
Log::error("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Error: ".$e->getMessage());
|
||||
|
||||
return null;
|
||||
@@ -475,7 +484,7 @@ function getTraefikVersionFromDockerCompose(Server $server): ?string
|
||||
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Image format doesn't match expected pattern: {$image}");
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
Log::error("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Error: ".$e->getMessage());
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\CoolifyTask\PrepareCoolifyTask;
|
||||
use App\Data\CoolifyTaskArgs;
|
||||
use App\Enums\ActivityTypes;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use App\Helpers\SshRetryHandler;
|
||||
use App\Jobs\CoolifyTask;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\PrivateKey;
|
||||
@@ -38,29 +39,46 @@ function remote_process(
|
||||
if (Auth::check()) {
|
||||
$teams = Auth::user()->teams->pluck('id');
|
||||
if (! $teams->contains($server->team_id) && ! $teams->contains(0)) {
|
||||
throw new \Exception('User is not part of the team that owns this server');
|
||||
throw new Exception('User is not part of the team that owns this server');
|
||||
}
|
||||
}
|
||||
|
||||
SshMultiplexingHelper::ensureMultiplexedConnection($server);
|
||||
|
||||
return resolve(PrepareCoolifyTask::class, [
|
||||
'remoteProcessArgs' => new CoolifyTaskArgs(
|
||||
server_uuid: $server->uuid,
|
||||
command: $command_string,
|
||||
type: $type,
|
||||
type_uuid: $type_uuid,
|
||||
model: $model,
|
||||
ignore_errors: $ignore_errors,
|
||||
call_event_on_finish: $callEventOnFinish,
|
||||
call_event_data: $callEventData,
|
||||
),
|
||||
])();
|
||||
$properties = [
|
||||
'server_uuid' => $server->uuid,
|
||||
'command' => $command_string,
|
||||
'type' => $type,
|
||||
'type_uuid' => $type_uuid,
|
||||
'status' => ProcessStatus::QUEUED->value,
|
||||
'team_id' => $server->team_id,
|
||||
];
|
||||
|
||||
$activityLog = activity()
|
||||
->withProperties($properties)
|
||||
->event($type);
|
||||
|
||||
if ($model) {
|
||||
$activityLog->performedOn($model);
|
||||
}
|
||||
|
||||
$activity = $activityLog->log('[]');
|
||||
|
||||
dispatch(new CoolifyTask(
|
||||
activity: $activity,
|
||||
ignore_errors: $ignore_errors,
|
||||
call_event_on_finish: $callEventOnFinish,
|
||||
call_event_data: $callEventData,
|
||||
));
|
||||
|
||||
$activity->refresh();
|
||||
|
||||
return $activity;
|
||||
}
|
||||
|
||||
function instant_scp(string $source, string $dest, Server $server, $throwError = true)
|
||||
{
|
||||
return \App\Helpers\SshRetryHandler::retry(
|
||||
return SshRetryHandler::retry(
|
||||
function () use ($source, $dest, $server) {
|
||||
$scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest);
|
||||
$process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
|
||||
@@ -92,7 +110,7 @@ function instant_remote_process_with_timeout(Collection|array $command, Server $
|
||||
}
|
||||
$command_string = implode("\n", $command);
|
||||
|
||||
return \App\Helpers\SshRetryHandler::retry(
|
||||
return SshRetryHandler::retry(
|
||||
function () use ($server, $command_string) {
|
||||
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
|
||||
$process = Process::timeout(30)->run($sshCommand);
|
||||
@@ -128,7 +146,7 @@ function instant_remote_process(Collection|array $command, Server $server, bool
|
||||
$command_string = implode("\n", $command);
|
||||
$effectiveTimeout = $timeout ?? config('constants.ssh.command_timeout');
|
||||
|
||||
return \App\Helpers\SshRetryHandler::retry(
|
||||
return SshRetryHandler::retry(
|
||||
function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) {
|
||||
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing);
|
||||
$process = Process::timeout($effectiveTimeout)->run($sshCommand);
|
||||
@@ -170,9 +188,9 @@ function excludeCertainErrors(string $errorOutput, ?int $exitCode = null)
|
||||
|
||||
if ($ignored) {
|
||||
// TODO: Create new exception and disable in sentry
|
||||
throw new \RuntimeException($errorMessage, $exitCode);
|
||||
throw new RuntimeException($errorMessage, $exitCode);
|
||||
}
|
||||
throw new \RuntimeException($errorMessage, $exitCode);
|
||||
throw new RuntimeException($errorMessage, $exitCode);
|
||||
}
|
||||
|
||||
function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null, bool $includeAll = false): Collection
|
||||
@@ -182,6 +200,12 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
|
||||
}
|
||||
$application = Application::find(data_get($application_deployment_queue, 'application_id'));
|
||||
$is_debug_enabled = data_get($application, 'settings.is_debug_enabled');
|
||||
$serverTimezone = getServerTimezone(data_get($application, 'destination.server'));
|
||||
|
||||
// Members should never see debug logs, even if an admin enabled debug mode
|
||||
if ($is_debug_enabled && auth()->check() && auth()->user()->isMember()) {
|
||||
$is_debug_enabled = false;
|
||||
}
|
||||
|
||||
$logs = data_get($application_deployment_queue, 'logs');
|
||||
if (empty($logs)) {
|
||||
@@ -194,7 +218,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
|
||||
associative: true,
|
||||
flags: JSON_THROW_ON_ERROR
|
||||
);
|
||||
} catch (\JsonException $e) {
|
||||
} catch (JsonException $e) {
|
||||
// If JSON decoding fails, try to clean up the logs and retry
|
||||
try {
|
||||
// Ensure valid UTF-8 encoding
|
||||
@@ -204,7 +228,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
|
||||
associative: true,
|
||||
flags: JSON_THROW_ON_ERROR
|
||||
);
|
||||
} catch (\JsonException $e) {
|
||||
} catch (JsonException $e) {
|
||||
// If it still fails, return empty collection to prevent crashes
|
||||
return collect([]);
|
||||
}
|
||||
@@ -222,8 +246,14 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
|
||||
|
||||
return $formatted
|
||||
->sortBy(fn ($i) => data_get($i, 'order'))
|
||||
->map(function ($i) {
|
||||
data_set($i, 'timestamp', Carbon::parse(data_get($i, 'timestamp'))->format('Y-M-d H:i:s.u'));
|
||||
->map(function ($i) use ($serverTimezone) {
|
||||
$timestamp = Carbon::parse(data_get($i, 'timestamp'));
|
||||
try {
|
||||
$timestamp->setTimezone($serverTimezone);
|
||||
} catch (Exception) {
|
||||
$timestamp->setTimezone('UTC');
|
||||
}
|
||||
data_set($i, 'timestamp', $timestamp->format('Y-M-d H:i:s.u'));
|
||||
|
||||
return $i;
|
||||
})
|
||||
@@ -353,7 +383,7 @@ function checkRequiredCommands(Server $server)
|
||||
}
|
||||
try {
|
||||
instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'apt update && apt install -y {$command}'"], $server);
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
break;
|
||||
}
|
||||
$commandFound = instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'command -v {$command}'"], $server, false);
|
||||
|
||||
+546
-176
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user