Merge remote-tracking branch 'origin/next' into pr-8371-v4.x

This commit is contained in:
Andras Bacsai committed 2026-03-29 23:52:37 +02:00
commit 7b1e75f37d
622 files changed
+52885 -5894

No files matched your search

@@ -1,54 +0,0 @@
<?php
namespace App\Actions\CoolifyTask;
use App\Data\CoolifyTaskArgs;
use App\Jobs\CoolifyTask;
use Spatie\Activitylog\Models\Activity;
/**
* The initial step to run a `CoolifyTask`: a remote SSH process
* with monitoring/tracking/trace feature. Such thing is made
* possible using an Activity model and some attributes.
*/
class PrepareCoolifyTask
{
protected Activity $activity;
protected CoolifyTaskArgs $remoteProcessArgs;
public function __construct(CoolifyTaskArgs $remoteProcessArgs)
{
$this->remoteProcessArgs = $remoteProcessArgs;
if ($remoteProcessArgs->model) {
$properties = $remoteProcessArgs->toArray();
unset($properties['model']);
$this->activity = activity()
->withProperties($properties)
->performedOn($remoteProcessArgs->model)
->event($remoteProcessArgs->type)
->log('[]');
} else {
$this->activity = activity()
->withProperties($remoteProcessArgs->toArray())
->event($remoteProcessArgs->type)
->log('[]');
}
}
public function __invoke(): Activity
{
$job = new CoolifyTask(
activity: $this->activity,
ignore_errors: $this->remoteProcessArgs->ignore_errors,
call_event_on_finish: $this->remoteProcessArgs->call_event_on_finish,
call_event_data: $this->remoteProcessArgs->call_event_data,
);
dispatch($job);
$this->activity->refresh();
return $this->activity;
}
}
+61 -9
View File
@@ -51,9 +51,11 @@ class StartDatabaseProxy
}
$configuration_dir = database_proxy_dir($database->uuid);
$host_configuration_dir = $configuration_dir;
if (isDev()) {
$configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy';
$host_configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy';
}
$timeoutConfig = $this->buildProxyTimeoutConfig($database->public_port_timeout);
$nginxconf = <<<EOF
user nginx;
worker_processes auto;
@@ -67,6 +69,7 @@ class StartDatabaseProxy
server {
listen $database->public_port;
proxy_pass $containerName:$internalPort;
$timeoutConfig
}
}
EOF;
@@ -85,7 +88,7 @@ class StartDatabaseProxy
'volumes' => [
[
'type' => 'bind',
'source' => "$configuration_dir/nginx.conf",
'source' => "$host_configuration_dir/nginx.conf",
'target' => '/etc/nginx/nginx.conf',
],
],
@@ -112,12 +115,61 @@ class StartDatabaseProxy
$dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2));
$nginxconf_base64 = base64_encode($nginxconf);
instant_remote_process(["docker rm -f $proxyContainerName"], $server, false);
instant_remote_process([
"mkdir -p $configuration_dir",
"echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
"echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
"docker compose --project-directory {$configuration_dir} pull",
"docker compose --project-directory {$configuration_dir} up -d",
], $server);
try {
instant_remote_process([
"mkdir -p $configuration_dir",
"echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
"echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
"docker compose --project-directory {$configuration_dir} pull",
"docker compose --project-directory {$configuration_dir} up -d",
], $server);
} catch (\RuntimeException $e) {
if ($this->isNonTransientError($e->getMessage())) {
$database->update(['is_public' => false]);
$team = data_get($database, 'environment.project.team')
?? data_get($database, 'service.environment.project.team');
$team?->notify(
new \App\Notifications\Container\ContainerRestarted(
"TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}",
$server,
)
);
ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}");
return;
}
throw $e;
}
}
private function isNonTransientError(string $message): bool
{
$nonTransientPatterns = [
'port is already allocated',
'address already in use',
'Bind for',
];
foreach ($nonTransientPatterns as $pattern) {
if (str_contains($message, $pattern)) {
return true;
}
}
return false;
}
private function buildProxyTimeoutConfig(?int $timeout): string
{
if ($timeout === null || $timeout < 1) {
$timeout = 3600;
}
return "proxy_timeout {$timeout}s;";
}
}
+3
View File
@@ -207,6 +207,9 @@ class StartKeydb
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) {
$this->commands[] = "chown 999:999 $this->configuration_dir/keydb.conf";
}
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
+3
View File
@@ -204,6 +204,9 @@ class StartRedis
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) {
$this->commands[] = "chown 999:999 $this->configuration_dir/redis.conf";
}
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
@@ -327,6 +327,12 @@ class GetContainersStatus
if (str($exitedService->status)->startsWith('exited')) {
continue;
}
// Only protection: If no containers at all, Docker query might have failed
if ($this->containers->isEmpty()) {
continue;
}
$name = data_get($exitedService, 'name');
$fqdn = data_get($exitedService, 'fqdn');
if ($name) {
@@ -406,6 +412,12 @@ class GetContainersStatus
if (str($database->status)->startsWith('exited')) {
continue;
}
// Only protection: If no containers at all, Docker query might have failed
if ($this->containers->isEmpty()) {
continue;
}
// Reset restart tracking when database exits completely
$database->update([
'status' => 'exited',
+2 -1
View File
@@ -37,12 +37,13 @@ class CreateNewUser implements CreatesNewUsers
if (User::count() == 0) {
// If this is the first user, make them the root user
// Team is already created in the database/seeders/ProductionSeeder.php
$user = User::create([
$user = (new User)->forceFill([
'id' => 0,
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
$user->save();
$team = $user->teams()->first();
// Disable registration after first user is created
+77 -11
View File
@@ -2,9 +2,12 @@
namespace App\Actions\Proxy;
use App\Enums\ProxyTypes;
use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
class GetProxyConfiguration
{
@@ -17,28 +20,42 @@ class GetProxyConfiguration
return 'OK';
}
$proxy_path = $server->proxyPath();
$proxy_configuration = null;
// If not forcing regeneration, try to read existing configuration
if (! $forceRegenerate) {
$payload = [
"mkdir -p $proxy_path",
"cat $proxy_path/docker-compose.yml 2>/dev/null",
];
$proxy_configuration = instant_remote_process($payload, $server, false);
// Primary source: database
$proxy_configuration = $server->proxy->get('last_saved_proxy_configuration');
// Validate stored config matches current proxy type
if (! empty(trim($proxy_configuration ?? ''))) {
if (! $this->configMatchesProxyType($proxyType, $proxy_configuration)) {
Log::warning('Stored proxy config does not match current proxy type, will regenerate', [
'server_id' => $server->id,
'proxy_type' => $proxyType,
]);
$proxy_configuration = null;
}
}
// Backfill: existing servers may not have DB config yet — read from disk once
if (empty(trim($proxy_configuration ?? ''))) {
$proxy_configuration = $this->backfillFromDisk($server);
}
}
// Generate default configuration if:
// 1. Force regenerate is requested
// 2. Configuration file doesn't exist or is empty
// Generate default configuration as last resort
if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) {
// Extract custom commands from existing config before regenerating
$custom_commands = [];
if (! empty(trim($proxy_configuration ?? ''))) {
$custom_commands = extractCustomProxyCommands($server, $proxy_configuration);
}
Log::warning('Proxy configuration regenerated to defaults', [
'server_id' => $server->id,
'server_name' => $server->name,
'reason' => $forceRegenerate ? 'force_regenerate' : 'config_not_found',
]);
$proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value();
}
@@ -50,4 +67,53 @@ class GetProxyConfiguration
return $proxy_configuration;
}
/**
* Check that the stored docker-compose YAML contains the expected service
* for the server's current proxy type. Returns false if the config belongs
* to a different proxy type (e.g. Traefik config on a CADDY server).
*/
private function configMatchesProxyType(string $proxyType, string $configuration): bool
{
try {
$yaml = Yaml::parse($configuration);
$services = data_get($yaml, 'services', []);
return match ($proxyType) {
ProxyTypes::TRAEFIK->value => isset($services['traefik']),
ProxyTypes::CADDY->value => isset($services['caddy']),
ProxyTypes::NGINX->value => isset($services['nginx']),
default => true,
};
} catch (\Throwable $e) {
// If YAML is unparseable, don't block — let the existing flow handle it
return true;
}
}
/**
* Backfill: read config from disk for servers that predate DB storage.
* Stores the result in the database so future reads skip SSH entirely.
*/
private function backfillFromDisk(Server $server): ?string
{
$proxy_path = $server->proxyPath();
$result = instant_remote_process([
"mkdir -p $proxy_path",
"cat $proxy_path/docker-compose.yml 2>/dev/null",
], $server, false);
if (! empty(trim($result ?? ''))) {
$server->proxy->last_saved_proxy_configuration = $result;
$server->save();
Log::info('Proxy config backfilled to database from disk', [
'server_id' => $server->id,
]);
return $result;
}
return null;
}
}
+29 -7
View File
@@ -9,19 +9,41 @@ class SaveProxyConfiguration
{
use AsAction;
private const MAX_BACKUPS = 10;
public function handle(Server $server, string $configuration): void
{
$proxy_path = $server->proxyPath();
$docker_compose_yml_base64 = base64_encode($configuration);
$new_hash = str($docker_compose_yml_base64)->pipe('md5')->value;
// Update the saved settings hash
$server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value;
// Only create a backup if the configuration actually changed
$old_hash = $server->proxy->get('last_saved_settings');
$config_changed = $old_hash && $old_hash !== $new_hash;
// Update the saved settings hash and store full config as database backup
$server->proxy->last_saved_settings = $new_hash;
$server->proxy->last_saved_proxy_configuration = $configuration;
$server->save();
// Transfer the configuration file to the server
instant_remote_process([
"mkdir -p $proxy_path",
"echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null",
], $server);
$backup_path = "$proxy_path/backups";
// Transfer the configuration file to the server, with backup if changed
$commands = ["mkdir -p $proxy_path"];
if ($config_changed) {
$short_hash = substr($old_hash, 0, 8);
$timestamp = now()->format('Y-m-d_H-i-s');
$backup_file = "docker-compose.{$timestamp}.{$short_hash}.yml";
$commands[] = "mkdir -p $backup_path";
// Skip backup if a file with the same hash already exists (identical content)
$commands[] = "ls $backup_path/docker-compose.*.$short_hash.yml 1>/dev/null 2>&1 || cp -f $proxy_path/docker-compose.yml $backup_path/$backup_file 2>/dev/null || true";
// Prune old backups, keep only the most recent ones
$commands[] = 'cd '.$backup_path.' && ls -1t docker-compose.*.yml 2>/dev/null | tail -n +'.((int) self::MAX_BACKUPS + 1).' | xargs rm -f 2>/dev/null || true';
}
$commands[] = "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null";
instant_remote_process($commands, $server);
}
}
+23 -2
View File
@@ -177,9 +177,10 @@ class CleanupDocker
->filter(fn ($image) => ! empty($image['tag']));
// Separate images into categories
// PR images (pr-*) and build images (*-build) are excluded from retention
// Build images will be cleaned up by docker image prune -af
// PR images (pr-*) are always deleted
// Build images (*-build) are cleaned up to match retained regular images
$prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-'));
$buildImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && str_ends_with($image['tag'], '-build'));
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
// Always delete all PR images
@@ -209,6 +210,26 @@ class CleanupDocker
'output' => $deleteOutput ?? 'Image removed or was in use',
];
}
// Clean up build images (-build suffix) that don't correspond to retained regular images
// Build images are intermediate artifacts (e.g. Nixpacks) not used by running containers.
// If a build is in progress, docker rmi will fail silently since the image is in use.
$keptTags = $sortedRegularImages->take($imagesToKeep)->pluck('tag');
if (! empty($currentTag)) {
$keptTags = $keptTags->push($currentTag);
}
foreach ($buildImages as $image) {
$baseTag = preg_replace('/-build$/', '', $image['tag']);
if (! $keptTags->contains($baseTag)) {
$deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true";
$deleteOutput = instant_remote_process([$deleteCommand], $server, false);
$cleanupLog[] = [
'command' => $deleteCommand,
'output' => $deleteOutput ?? 'Build image removed or was in use',
];
}
}
}
return $cleanupLog;
+7 -12
View File
@@ -11,11 +11,8 @@ class InstallDocker
{
use AsAction;
private string $dockerVersion;
public function handle(Server $server)
{
$this->dockerVersion = config('constants.docker.minimum_required_version');
$supported_os_type = $server->validateOS();
if (! $supported_os_type) {
throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://coolify.io/docs/installation#manually">documentation</a>.');
@@ -30,12 +27,14 @@ class InstallDocker
);
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
$base64Cert = base64_encode($serverCert->ssl_certificate);
$commands = collect([
"mkdir -p $caCertPath",
"chown -R 9999:root $caCertPath",
"chmod -R 700 $caCertPath",
"rm -rf $caCertPath/coolify-ca.crt",
"echo '{$serverCert->ssl_certificate}' > $caCertPath/coolify-ca.crt",
"echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
"chmod 644 $caCertPath/coolify-ca.crt",
]);
remote_process($commands, $server);
@@ -116,7 +115,7 @@ class InstallDocker
private function getDebianDockerInstallCommand(): string
{
return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
return 'curl -fsSL https://get.docker.com | sh || ('.
'. /etc/os-release && '.
'install -m 0755 -d /etc/apt/keyrings && '.
'curl -fsSL https://download.docker.com/linux/${ID}/gpg -o /etc/apt/keyrings/docker.asc && '.
@@ -129,7 +128,7 @@ class InstallDocker
private function getRhelDockerInstallCommand(): string
{
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
return 'curl -fsSL https://get.docker.com | sh || ('.
'dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo && '.
'dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '.
'systemctl start docker && '.
@@ -139,7 +138,7 @@ class InstallDocker
private function getSuseDockerInstallCommand(): string
{
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
return 'curl -fsSL https://get.docker.com | sh || ('.
'zypper addrepo https://download.docker.com/linux/sles/docker-ce.repo && '.
'zypper refresh && '.
'zypper install -y --no-confirm docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '.
@@ -150,10 +149,6 @@ class InstallDocker
private function getArchDockerInstallCommand(): string
{
// Use -Syu to perform full system upgrade before installing Docker
// Partial upgrades (-Sy without -u) are discouraged on Arch Linux
// as they can lead to broken dependencies and system instability
// Use --needed to skip reinstalling packages that are already up-to-date (idempotent)
return 'pacman -Syu --noconfirm --needed docker docker-compose && '.
'systemctl enable docker.service && '.
'systemctl start docker.service';
@@ -161,6 +156,6 @@ class InstallDocker
private function getGenericDockerInstallCommand(): string
{
return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion}";
return 'curl -fsSL https://get.docker.com | sh';
}
}
+14 -25
View File
@@ -177,6 +177,19 @@ Files:
$parsers_config = $config_path.'/parsers.conf';
$compose_path = $config_path.'/docker-compose.yml';
$readme_path = $config_path.'/README.md';
if ($type === 'newrelic') {
$envContent = "LICENSE_KEY={$license_key}\nBASE_URI={$base_uri}\n";
} elseif ($type === 'highlight') {
$envContent = "HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id}\n";
} elseif ($type === 'axiom') {
$envContent = "AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$server->settings->logdrain_axiom_api_key}\n";
} elseif ($type === 'custom') {
$envContent = '';
} else {
throw new \Exception('Unknown log drain type.');
}
$envEncoded = base64_encode($envContent);
$command = [
"echo 'Saving configuration'",
"mkdir -p $config_path",
@@ -184,34 +197,10 @@ Files:
"echo '{$config}' | base64 -d | tee $fluent_bit_config > /dev/null",
"echo '{$compose}' | base64 -d | tee $compose_path > /dev/null",
"echo '{$readme}' | base64 -d | tee $readme_path > /dev/null",
"test -f $config_path/.env && rm $config_path/.env",
];
if ($type === 'newrelic') {
$add_envs_command = [
"echo LICENSE_KEY=$license_key >> $config_path/.env",
"echo BASE_URI=$base_uri >> $config_path/.env",
];
} elseif ($type === 'highlight') {
$add_envs_command = [
"echo HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id} >> $config_path/.env",
];
} elseif ($type === 'axiom') {
$add_envs_command = [
"echo AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name} >> $config_path/.env",
"echo AXIOM_API_KEY={$server->settings->logdrain_axiom_api_key} >> $config_path/.env",
];
} elseif ($type === 'custom') {
$add_envs_command = [
"touch $config_path/.env",
];
} else {
throw new \Exception('Unknown log drain type.');
}
$restart_command = [
"echo '{$envEncoded}' | base64 -d | tee $config_path/.env > /dev/null",
"echo 'Starting Fluent Bit'",
"cd $config_path && docker compose up -d",
];
$command = array_merge($command, $add_envs_command, $restart_command);
return instant_remote_process($command, $server);
} catch (\Throwable $e) {
+5 -1
View File
@@ -4,6 +4,7 @@ namespace App\Actions\Server;
use App\Events\SentinelRestarted;
use App\Models\Server;
use App\Models\ServerSetting;
use Lorisleiva\Actions\Concerns\AsAction;
class StartSentinel
@@ -23,6 +24,9 @@ class StartSentinel
$refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds');
$pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds');
$token = data_get($server, 'settings.sentinel_token');
if (! ServerSetting::isValidSentinelToken($token)) {
throw new \RuntimeException('Invalid sentinel token format. Token must contain only alphanumeric characters, dots, hyphens, and underscores.');
}
$endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel';
@@ -49,7 +53,7 @@ class StartSentinel
}
$mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel';
}
$dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"';
$dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments));
$dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels));
$dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
+2 -1
View File
@@ -30,7 +30,8 @@ class ValidateServer
]);
['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection();
if (! $this->uptime) {
$this->error = 'Server is not reachable. Please validate your configuration and connection.<br>Check this <a target="_blank" class="text-black underline dark:text-white" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br><div class="text-error">Error: '.$error.'</div>';
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
$this->error = 'Server is not reachable. Please validate your configuration and connection.<br>Check this <a target="_blank" class="text-black underline dark:text-white" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br><div class="text-error">Error: '.$sanitizedError.'</div>';
$server->update([
'validation_logs' => $this->error,
]);
+1 -1
View File
@@ -33,7 +33,7 @@ class DeleteService
}
}
foreach ($storagesToDelete as $storage) {
$commands[] = "docker volume rm -f $storage->name";
$commands[] = 'docker volume rm -f '.escapeshellarg($storage->name);
}
// Execute volume deletion first, this must be done first otherwise volumes will not be deleted.
+2 -2
View File
@@ -40,10 +40,10 @@ class StartService
$commands[] = "docker network connect $service->uuid coolify-proxy >/dev/null 2>&1 || true";
if (data_get($service, 'connect_to_docker_network')) {
$compose = data_get($service, 'docker_compose', []);
$network = $service->destination->network;
$safeNetwork = escapeshellarg($service->destination->network);
$serviceNames = data_get(Yaml::parse($compose), 'services', []);
foreach ($serviceNames as $serviceName => $serviceConfig) {
$commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} $network {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true";
$commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} {$safeNetwork} {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true";
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Actions\Stripe;
use App\Models\Team;
use Stripe\StripeClient;
class CancelSubscriptionAtPeriodEnd
{
private StripeClient $stripe;
public function __construct(?StripeClient $stripe = null)
{
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
}
/**
* Cancel the team's subscription at the end of the current billing period.
*
* @return array{success: bool, error: string|null}
*/
public function execute(Team $team): array
{
$subscription = $team->subscription;
if (! $subscription?->stripe_subscription_id) {
return ['success' => false, 'error' => 'No active subscription found.'];
}
if (! $subscription->stripe_invoice_paid) {
return ['success' => false, 'error' => 'Subscription is not active.'];
}
if ($subscription->stripe_cancel_at_period_end) {
return ['success' => false, 'error' => 'Subscription is already set to cancel at the end of the billing period.'];
}
try {
$this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
'cancel_at_period_end' => true,
]);
$subscription->update([
'stripe_cancel_at_period_end' => true,
]);
\Log::info("Subscription {$subscription->stripe_subscription_id} set to cancel at period end for team {$team->name}");
return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) {
\Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
} catch (\Exception $e) {
\Log::error("Cancel at period end error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
}
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
namespace App\Actions\Stripe;
use App\Models\Team;
use Stripe\StripeClient;
class RefundSubscription
{
private StripeClient $stripe;
private const REFUND_WINDOW_DAYS = 30;
public function __construct(?StripeClient $stripe = null)
{
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
}
/**
* Check if the team's subscription is eligible for a refund.
*
* @return array{eligible: bool, days_remaining: int, reason: string, current_period_end: int|null}
*/
public function checkEligibility(Team $team): array
{
$subscription = $team->subscription;
if ($subscription?->stripe_refunded_at) {
return $this->ineligible('A refund has already been processed for this team.');
}
if (! $subscription?->stripe_subscription_id) {
return $this->ineligible('No active subscription found.');
}
if (! $subscription->stripe_invoice_paid) {
return $this->ineligible('Subscription invoice is not paid.');
}
try {
$stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
} catch (\Stripe\Exception\InvalidRequestException $e) {
return $this->ineligible('Subscription not found in Stripe.');
}
$currentPeriodEnd = $stripeSubscription->current_period_end;
if (! in_array($stripeSubscription->status, ['active', 'trialing'])) {
return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd);
}
$startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date);
$daysSinceStart = (int) $startDate->diffInDays(now());
$daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart;
if ($daysRemaining <= 0) {
return $this->ineligible('The 30-day refund window has expired.', $currentPeriodEnd);
}
return [
'eligible' => true,
'days_remaining' => $daysRemaining,
'reason' => 'Eligible for refund.',
'current_period_end' => $currentPeriodEnd,
];
}
/**
* Process a full refund and cancel the subscription.
*
* @return array{success: bool, error: string|null}
*/
public function execute(Team $team): array
{
$eligibility = $this->checkEligibility($team);
if (! $eligibility['eligible']) {
return ['success' => false, 'error' => $eligibility['reason']];
}
$subscription = $team->subscription;
try {
$invoices = $this->stripe->invoices->all([
'subscription' => $subscription->stripe_subscription_id,
'status' => 'paid',
'limit' => 1,
]);
if (empty($invoices->data)) {
return ['success' => false, 'error' => 'No paid invoice found to refund.'];
}
$invoice = $invoices->data[0];
$paymentIntentId = $invoice->payment_intent;
if (! $paymentIntentId) {
return ['success' => false, 'error' => 'No payment intent found on the invoice.'];
}
$this->stripe->refunds->create([
'payment_intent' => $paymentIntentId,
]);
// Record refund immediately so it cannot be retried if cancel fails
$subscription->update([
'stripe_refunded_at' => now(),
'stripe_feedback' => 'Refund requested by user',
'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(),
]);
try {
$this->stripe->subscriptions->cancel($subscription->stripe_subscription_id);
} catch (\Exception $e) {
\Log::critical("Refund succeeded but subscription cancel failed for team {$team->id}: ".$e->getMessage());
send_internal_notification(
"CRITICAL: Refund succeeded but cancel failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual intervention required."
);
}
$subscription->update([
'stripe_cancel_at_period_end' => false,
'stripe_invoice_paid' => false,
'stripe_trial_already_ended' => false,
'stripe_past_due' => false,
]);
$team->subscriptionEnded();
\Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}");
return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) {
\Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
} catch (\Exception $e) {
\Log::error("Refund error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
}
}
/**
* @return array{eligible: bool, days_remaining: int, reason: string, current_period_end: int|null}
*/
private function ineligible(string $reason, ?int $currentPeriodEnd = null): array
{
return [
'eligible' => false,
'days_remaining' => 0,
'reason' => $reason,
'current_period_end' => $currentPeriodEnd,
];
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Actions\Stripe;
use App\Models\Team;
use Stripe\StripeClient;
class ResumeSubscription
{
private StripeClient $stripe;
public function __construct(?StripeClient $stripe = null)
{
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
}
/**
* Resume a subscription that was set to cancel at the end of the billing period.
*
* @return array{success: bool, error: string|null}
*/
public function execute(Team $team): array
{
$subscription = $team->subscription;
if (! $subscription?->stripe_subscription_id) {
return ['success' => false, 'error' => 'No active subscription found.'];
}
if (! $subscription->stripe_cancel_at_period_end) {
return ['success' => false, 'error' => 'Subscription is not set to cancel.'];
}
try {
$this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
'cancel_at_period_end' => false,
]);
$subscription->update([
'stripe_cancel_at_period_end' => false,
]);
\Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}");
return ['success' => true, 'error' => null];
} catch (\Stripe\Exception\InvalidRequestException $e) {
\Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
} catch (\Exception $e) {
\Log::error("Resume subscription error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
}
}
}
@@ -0,0 +1,207 @@
<?php
namespace App\Actions\Stripe;
use App\Jobs\ServerLimitCheckJob;
use App\Models\Team;
use Stripe\Exception\InvalidRequestException;
use Stripe\StripeClient;
class UpdateSubscriptionQuantity
{
public const int MAX_SERVER_LIMIT = 100;
public const int MIN_SERVER_LIMIT = 2;
private StripeClient $stripe;
public function __construct(?StripeClient $stripe = null)
{
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
}
/**
* Fetch a full price preview for a quantity change from Stripe.
* Returns both the prorated amount due now and the recurring cost for the next billing cycle.
*
* @return array{success: bool, error: string|null, preview: array{due_now: int, recurring_subtotal: int, recurring_tax: int, recurring_total: int, unit_price: int, tax_description: string|null, quantity: int, currency: string}|null}
*/
public function fetchPricePreview(Team $team, int $quantity): array
{
$subscription = $team->subscription;
if (! $subscription?->stripe_subscription_id || ! $subscription->stripe_invoice_paid) {
return ['success' => false, 'error' => 'No active subscription found.', 'preview' => null];
}
try {
$stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
$item = $stripeSubscription->items->data[0] ?? null;
if (! $item) {
return ['success' => false, 'error' => 'Could not retrieve subscription details.', 'preview' => null];
}
$currency = strtoupper($item->price->currency ?? 'usd');
$billingInterval = $item->price->recurring->interval ?? 'month';
// Upcoming invoice gives us the prorated amount due now
$upcomingInvoice = $this->stripe->invoices->upcoming([
'customer' => $subscription->stripe_customer_id,
'subscription' => $subscription->stripe_subscription_id,
'subscription_items' => [
['id' => $item->id, 'quantity' => $quantity],
],
'subscription_proration_behavior' => 'create_prorations',
]);
// Extract tax percentage — try total_tax_amounts first, fall back to invoice tax/subtotal
$taxPercentage = 0.0;
$taxDescription = null;
if (! empty($upcomingInvoice->total_tax_amounts)) {
$taxAmount = $upcomingInvoice->total_tax_amounts[0] ?? null;
if ($taxAmount?->tax_rate) {
$taxRate = $this->stripe->taxRates->retrieve($taxAmount->tax_rate);
$taxPercentage = (float) ($taxRate->percentage ?? 0);
$taxDescription = $taxRate->display_name.' ('.$taxRate->jurisdiction.') '.$taxRate->percentage.'%';
}
}
// Fallback tax percentage from invoice totals - use tax_rate details when available for accuracy
if ($taxPercentage === 0.0 && ($upcomingInvoice->tax ?? 0) > 0 && ($upcomingInvoice->subtotal ?? 0) > 0) {
$taxPercentage = round(($upcomingInvoice->tax / $upcomingInvoice->subtotal) * 100, 2);
}
// Recurring cost for next cycle — read from non-proration invoice lines
$recurringSubtotal = 0;
foreach ($upcomingInvoice->lines->data as $line) {
if (! $line->proration) {
$recurringSubtotal += $line->amount;
}
}
$unitPrice = $quantity > 0 ? (int) round($recurringSubtotal / $quantity) : 0;
$recurringTax = $taxPercentage > 0
? (int) round($recurringSubtotal * $taxPercentage / 100)
: 0;
$recurringTotal = $recurringSubtotal + $recurringTax;
// Due now = amount_due (accounts for customer balance/credits) minus recurring
$amountDue = $upcomingInvoice->amount_due ?? $upcomingInvoice->total ?? 0;
$dueNow = $amountDue - $recurringTotal;
return [
'success' => true,
'error' => null,
'preview' => [
'due_now' => $dueNow,
'recurring_subtotal' => $recurringSubtotal,
'recurring_tax' => $recurringTax,
'recurring_total' => $recurringTotal,
'unit_price' => $unitPrice,
'tax_description' => $taxDescription,
'quantity' => $quantity,
'currency' => $currency,
'billing_interval' => $billingInterval,
],
];
} catch (\Exception $e) {
\Log::warning("Stripe fetch price preview error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Could not load price preview.', 'preview' => null];
}
}
/**
* Update the subscription quantity (server limit) for a team.
*
* @return array{success: bool, error: string|null}
*/
public function execute(Team $team, int $quantity): array
{
if ($quantity < self::MIN_SERVER_LIMIT) {
return ['success' => false, 'error' => 'Minimum server limit is '.self::MIN_SERVER_LIMIT.'.'];
}
$subscription = $team->subscription;
if (! $subscription?->stripe_subscription_id) {
return ['success' => false, 'error' => 'No active subscription found.'];
}
if (! $subscription->stripe_invoice_paid) {
return ['success' => false, 'error' => 'Subscription is not active.'];
}
try {
$stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
$item = $stripeSubscription->items->data[0] ?? null;
if (! $item?->id) {
return ['success' => false, 'error' => 'Could not find subscription item.'];
}
$previousQuantity = $item->quantity ?? $team->custom_server_limit;
$updatedSubscription = $this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
'items' => [
['id' => $item->id, 'quantity' => $quantity],
],
'proration_behavior' => 'always_invoice',
'expand' => ['latest_invoice'],
]);
// Check if the proration invoice was paid
$latestInvoice = $updatedSubscription->latest_invoice;
if ($latestInvoice && $latestInvoice->status !== 'paid') {
\Log::warning("Subscription {$subscription->stripe_subscription_id} quantity updated but invoice not paid (status: {$latestInvoice->status}) for team {$team->name}. Reverting to {$previousQuantity}.");
// Revert subscription quantity on Stripe
try {
$this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
'items' => [
['id' => $item->id, 'quantity' => $previousQuantity],
],
'proration_behavior' => 'none',
]);
} catch (\Exception $revertException) {
\Log::critical("Failed to revert Stripe quantity for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Stripe may have quantity {$quantity} but local is {$previousQuantity}. Error: ".$revertException->getMessage());
send_internal_notification(
"CRITICAL: Stripe quantity revert failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual reconciliation required."
);
}
// Void the unpaid invoice
if ($latestInvoice->id) {
$this->stripe->invoices->voidInvoice($latestInvoice->id);
}
return ['success' => false, 'error' => 'Payment failed. Your server limit was not changed. Please check your payment method and try again.'];
}
$team->update([
'custom_server_limit' => $quantity,
]);
ServerLimitCheckJob::dispatch($team);
\Log::info("Subscription {$subscription->stripe_subscription_id} quantity updated to {$quantity} for team {$team->name}");
return ['success' => true, 'error' => null];
} catch (InvalidRequestException $e) {
\Log::error("Stripe update quantity error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
} catch (\Exception $e) {
\Log::error("Update subscription quantity error for team {$team->id}: ".$e->getMessage());
return ['success' => false, 'error' => 'An unexpected error occurred. Please contact support.'];
}
}
private function formatAmount(int $cents, string $currency): string
{
return strtoupper($currency) === 'USD'
? '$'.number_format($cents / 100, 2)
: number_format($cents / 100, 2).' '.$currency;
}
}
@@ -14,7 +14,7 @@ class CleanupUnreachableServers extends Command
public function handle()
{
echo "Running unreachable server cleanup...\n";
$servers = Server::where('unreachable_count', 3)->where('unreachable_notification_sent', true)->where('updated_at', '<', now()->subDays(7))->get();
$servers = Server::where('unreachable_count', '>=', 3)->where('unreachable_notification_sent', true)->where('updated_at', '<', now()->subDays(7))->get();
if ($servers->count() > 0) {
foreach ($servers as $server) {
echo "Cleanup unreachable server ($server->id) with name $server->name";
@@ -36,7 +36,14 @@ class SyncStripeSubscriptions extends Command
$this->newLine();
$job = new SyncStripeSubscriptionsJob($fix);
$result = $job->handle();
$fetched = 0;
$result = $job->handle(function (int $count) use (&$fetched): void {
$fetched = $count;
$this->output->write("\r Fetching subscriptions from Stripe... {$fetched}");
});
if ($fetched > 0) {
$this->output->write("\r".str_repeat(' ', 60)."\r");
}
if (isset($result['error'])) {
$this->error($result['error']);
@@ -68,6 +75,19 @@ class SyncStripeSubscriptions extends Command
$this->info('No discrepancies found. All subscriptions are in sync.');
}
if (count($result['resubscribed']) > 0) {
$this->newLine();
$this->warn('Resubscribed users (same email, different customer): '.count($result['resubscribed']));
$this->newLine();
foreach ($result['resubscribed'] as $resub) {
$this->line(" - Team ID: {$resub['team_id']} | Email: {$resub['email']}");
$this->line(" Old: {$resub['old_stripe_subscription_id']} (cus: {$resub['old_stripe_customer_id']})");
$this->line(" New: {$resub['new_stripe_subscription_id']} (cus: {$resub['new_stripe_customer_id']}) [{$resub['new_status']}]");
$this->newLine();
}
}
if (count($result['errors']) > 0) {
$this->newLine();
$this->error('Errors encountered: '.count($result['errors']));
+11 -11
View File
@@ -30,32 +30,32 @@ class Dev extends Command
// Generate APP_KEY if not exists
if (empty(config('app.key'))) {
echo "Generating APP_KEY.\n";
echo " INFO Generating APP_KEY.\n";
Artisan::call('key:generate');
}
// Generate STORAGE link if not exists
if (! file_exists(public_path('storage'))) {
echo "Generating STORAGE link.\n";
echo " INFO Generating storage link.\n";
Artisan::call('storage:link');
}
// Seed database if it's empty
$settings = InstanceSettings::find(0);
if (! $settings) {
echo "Initializing instance, seeding database.\n";
echo " INFO Initializing instance, seeding database.\n";
Artisan::call('migrate --seed');
} else {
echo "Instance already initialized.\n";
echo " INFO Instance already initialized.\n";
}
// Clean up stuck jobs and stale locks on development startup
try {
echo "Cleaning up Redis (stuck jobs and stale locks)...\n";
echo " INFO Cleaning up Redis (stuck jobs and stale locks)...\n";
Artisan::call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
echo "Redis cleanup completed.\n";
echo " INFO Redis cleanup completed.\n";
} catch (\Throwable $e) {
echo "Error in cleanup:redis: {$e->getMessage()}\n";
echo " ERROR Redis cleanup failed: {$e->getMessage()}\n";
}
try {
@@ -66,10 +66,10 @@ class Dev extends Command
]);
if ($updatedTaskCount > 0) {
echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
echo " INFO Marked {$updatedTaskCount} stuck scheduled task executions as failed.\n";
}
} catch (\Throwable $e) {
echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
echo " ERROR Could not clean up stuck scheduled task executions: {$e->getMessage()}\n";
}
try {
@@ -80,10 +80,10 @@ class Dev extends Command
]);
if ($updatedBackupCount > 0) {
echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
echo " INFO Marked {$updatedBackupCount} stuck database backup executions as failed.\n";
}
} catch (\Throwable $e) {
echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
echo " ERROR Could not clean up stuck database backup executions: {$e->getMessage()}\n";
}
CheckHelperImageJob::dispatch();
+2 -1
View File
@@ -32,7 +32,8 @@ class OpenApi extends Command
echo $process->output();
$yaml = file_get_contents('openapi.yaml');
$json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT);
$json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT)."\n";
file_put_contents('openapi.json', $json);
echo "Converted OpenAPI YAML to JSON.\n";
}
@@ -0,0 +1,222 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class GenerateTestingSchema extends Command
{
protected $signature = 'schema:generate-testing {--connection=pgsql : The database connection to read from}';
protected $description = 'Generate SQLite testing schema from the PostgreSQL database';
private array $typeMap = [
'/\bbigint\b/' => 'INTEGER',
'/\binteger\b/' => 'INTEGER',
'/\bsmallint\b/' => 'INTEGER',
'/\bboolean\b/' => 'INTEGER',
'/character varying\(\d+\)/' => 'TEXT',
'/timestamp\(\d+\) without time zone/' => 'TEXT',
'/timestamp\(\d+\) with time zone/' => 'TEXT',
'/\bjsonb\b/' => 'TEXT',
'/\bjson\b/' => 'TEXT',
'/\buuid\b/' => 'TEXT',
'/double precision/' => 'REAL',
'/numeric\(\d+,\d+\)/' => 'REAL',
'/\bdate\b/' => 'TEXT',
];
private array $castRemovals = [
'::character varying',
'::text',
'::integer',
'::boolean',
'::timestamp without time zone',
'::timestamp with time zone',
'::numeric',
];
public function handle(): int
{
$connection = $this->option('connection');
if (DB::connection($connection)->getDriverName() !== 'pgsql') {
$this->error("Connection '{$connection}' is not PostgreSQL.");
return self::FAILURE;
}
$this->info('Reading schema from PostgreSQL...');
$tables = $this->getTables($connection);
$lastMigration = DB::connection($connection)
->table('migrations')
->orderByDesc('id')
->value('migration');
$output = [];
$output[] = '-- Generated by: php artisan schema:generate-testing';
$output[] = '-- Date: '.now()->format('Y-m-d H:i:s');
$output[] = '-- Last migration: '.($lastMigration ?? 'none');
$output[] = '';
foreach ($tables as $table) {
$columns = $this->getColumns($connection, $table);
$output[] = $this->generateCreateTable($table, $columns);
}
$indexes = $this->getIndexes($connection, $tables);
foreach ($indexes as $index) {
$output[] = $index;
}
$output[] = '';
$output[] = '-- Migration records';
$migrations = DB::connection($connection)->table('migrations')->orderBy('id')->get();
foreach ($migrations as $m) {
$migration = str_replace("'", "''", $m->migration);
$output[] = "INSERT INTO \"migrations\" (\"id\", \"migration\", \"batch\") VALUES ({$m->id}, '{$migration}', {$m->batch});";
}
$path = database_path('schema/testing-schema.sql');
if (! is_dir(dirname($path))) {
mkdir(dirname($path), 0755, true);
}
file_put_contents($path, implode("\n", $output)."\n");
$this->info("Schema written to {$path}");
$this->info(count($tables).' tables, '.count($migrations).' migration records.');
return self::SUCCESS;
}
private function getTables(string $connection): array
{
return collect(DB::connection($connection)->select(
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"
))->pluck('tablename')->toArray();
}
private function getColumns(string $connection, string $table): array
{
return DB::connection($connection)->select(
"SELECT column_name, data_type, character_maximum_length, column_default,
is_nullable, udt_name, numeric_precision, numeric_scale, datetime_precision
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = ?
ORDER BY ordinal_position",
[$table]
);
}
private function generateCreateTable(string $table, array $columns): string
{
$lines = [];
foreach ($columns as $col) {
$lines[] = ' '.$this->generateColumnDef($table, $col);
}
return "CREATE TABLE IF NOT EXISTS \"{$table}\" (\n".implode(",\n", $lines)."\n);\n";
}
private function generateColumnDef(string $table, object $col): string
{
$name = $col->column_name;
$sqliteType = $this->convertType($col);
// Auto-increment primary key for id columns
if ($name === 'id' && $sqliteType === 'INTEGER' && $col->is_nullable === 'NO' && str_contains((string) $col->column_default, 'nextval')) {
return "\"{$name}\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL";
}
$parts = ["\"{$name}\"", $sqliteType];
// Default value
$default = $col->column_default;
if ($default !== null && ! str_contains($default, 'nextval')) {
$default = $this->cleanDefault($default);
$parts[] = "DEFAULT {$default}";
}
// NOT NULL
if ($col->is_nullable === 'NO') {
$parts[] = 'NOT NULL';
}
return implode(' ', $parts);
}
private function convertType(object $col): string
{
$pgType = $col->data_type;
return match (true) {
in_array($pgType, ['bigint', 'integer', 'smallint']) => 'INTEGER',
$pgType === 'boolean' => 'INTEGER',
in_array($pgType, ['character varying', 'text', 'USER-DEFINED']) => 'TEXT',
str_contains($pgType, 'timestamp') => 'TEXT',
in_array($pgType, ['json', 'jsonb']) => 'TEXT',
$pgType === 'uuid' => 'TEXT',
$pgType === 'double precision' => 'REAL',
$pgType === 'numeric' => 'REAL',
$pgType === 'date' => 'TEXT',
default => 'TEXT',
};
}
private function cleanDefault(string $default): string
{
foreach ($this->castRemovals as $cast) {
$default = str_replace($cast, '', $default);
}
// Remove array type casts like ::text[]
$default = preg_replace('/::[\w\s]+(\[\])?/', '', $default);
return $default;
}
private function getIndexes(string $connection, array $tables): array
{
$results = [];
$indexes = DB::connection($connection)->select(
"SELECT indexname, tablename, indexdef FROM pg_indexes
WHERE schemaname = 'public'
ORDER BY tablename, indexname"
);
foreach ($indexes as $idx) {
$def = $idx->indexdef;
// Skip primary key indexes
if (str_contains($def, '_pkey')) {
continue;
}
// Skip PG-specific indexes (GIN, GIST, expression indexes)
if (preg_match('/USING (gin|gist)/i', $def)) {
continue;
}
if (str_contains($def, '->>') || str_contains($def, '::')) {
continue;
}
// Convert to SQLite-compatible CREATE INDEX
$unique = str_contains($def, 'UNIQUE') ? 'UNIQUE ' : '';
// Extract columns from the index definition
if (preg_match('/\((.+)\)$/', $def, $m)) {
$cols = $m[1];
$results[] = "CREATE {$unique}INDEX IF NOT EXISTS \"{$idx->indexname}\" ON \"{$idx->tablename}\" ({$cols});";
}
}
return $results;
}
}
-23
View File
@@ -1,23 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class Horizon extends Command
{
protected $signature = 'start:horizon';
protected $description = 'Start Horizon';
public function handle()
{
if (config('constants.horizon.is_horizon_enabled')) {
$this->info('Horizon is enabled on this server.');
$this->call('horizon');
exit(0);
} else {
exit(0);
}
}
}
+6 -5
View File
@@ -212,18 +212,19 @@ class Init extends Command
$removeNetworks = $allNetworks->diff($networks);
$commands = collect();
foreach ($removeNetworks as $network) {
$out = instant_remote_process(["docker network inspect -f json $network | jq '.[].Containers | if . == {} then null else . end'"], $server, false);
$safe = escapeshellarg($network);
$out = instant_remote_process(["docker network inspect -f json {$safe} | jq '.[].Containers | if . == {} then null else . end'"], $server, false);
if (empty($out)) {
$commands->push("docker network disconnect $network coolify-proxy >/dev/null 2>&1 || true");
$commands->push("docker network rm $network >/dev/null 2>&1 || true");
$commands->push("docker network disconnect {$safe} coolify-proxy >/dev/null 2>&1 || true");
$commands->push("docker network rm {$safe} >/dev/null 2>&1 || true");
} else {
$data = collect(json_decode($out, true));
if ($data->count() === 1) {
// If only coolify-proxy itself is connected to that network (it should not be possible, but who knows)
$isCoolifyProxyItself = data_get($data->first(), 'Name') === 'coolify-proxy';
if ($isCoolifyProxyItself) {
$commands->push("docker network disconnect $network coolify-proxy >/dev/null 2>&1 || true");
$commands->push("docker network rm $network >/dev/null 2>&1 || true");
$commands->push("docker network disconnect {$safe} coolify-proxy >/dev/null 2>&1 || true");
$commands->push("docker network rm {$safe} >/dev/null 2>&1 || true");
}
}
}
@@ -0,0 +1,255 @@
<?php
namespace App\Console\Commands;
use App\Models\DockerCleanupExecution;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
class ScheduledJobDiagnostics extends Command
{
protected $signature = 'scheduled:diagnostics
{--type=all : Type to inspect: docker-cleanup, backups, tasks, server-jobs, all}
{--server= : Filter by server ID}';
protected $description = 'Inspect dedup cache state and scheduling decisions for all scheduled jobs';
public function handle(): int
{
$type = $this->option('type');
$serverFilter = $this->option('server');
$this->outputHeartbeat();
if (in_array($type, ['all', 'docker-cleanup'])) {
$this->inspectDockerCleanups($serverFilter);
}
if (in_array($type, ['all', 'backups'])) {
$this->inspectBackups();
}
if (in_array($type, ['all', 'tasks'])) {
$this->inspectTasks();
}
if (in_array($type, ['all', 'server-jobs'])) {
$this->inspectServerJobs($serverFilter);
}
return self::SUCCESS;
}
private function outputHeartbeat(): void
{
$heartbeat = Cache::get('scheduled-job-manager:heartbeat');
if ($heartbeat) {
$age = Carbon::parse($heartbeat)->diffForHumans();
$this->info("Scheduler heartbeat: {$heartbeat} ({$age})");
} else {
$this->error('Scheduler heartbeat: MISSING — ScheduledJobManager may not be running');
}
$this->newLine();
}
private function inspectDockerCleanups(?string $serverFilter): void
{
$this->info('=== Docker Cleanup Jobs ===');
$servers = $this->getServers($serverFilter);
$rows = [];
foreach ($servers as $server) {
$frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
$dedupKey = "docker-cleanup:{$server->id}";
$cacheValue = Cache::get($dedupKey);
$timezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
if (validate_timezone($timezone) === false) {
$timezone = config('app.timezone');
}
$wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey);
$lastExecution = DockerCleanupExecution::where('server_id', $server->id)
->latest()
->first();
$rows[] = [
$server->id,
$server->name,
$timezone,
$frequency,
$dedupKey,
$cacheValue ?? '<missing>',
$wouldFire ? 'YES' : 'no',
$lastExecution ? $lastExecution->status.' @ '.$lastExecution->created_at : 'never',
];
}
$this->table(
['ID', 'Server', 'TZ', 'Frequency', 'Dedup Key', 'Cache Value', 'Would Fire', 'Last Execution'],
$rows
);
$this->newLine();
}
private function inspectBackups(): void
{
$this->info('=== Scheduled Backups ===');
$backups = ScheduledDatabaseBackup::with(['database'])
->where('enabled', true)
->get();
$rows = [];
foreach ($backups as $backup) {
$server = $backup->server();
$frequency = $backup->frequency;
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
$dedupKey = "scheduled-backup:{$backup->id}";
$cacheValue = Cache::get($dedupKey);
$timezone = $server ? data_get($server->settings, 'server_timezone', config('app.timezone')) : config('app.timezone');
if (validate_timezone($timezone) === false) {
$timezone = config('app.timezone');
}
$wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey);
$rows[] = [
$backup->id,
$backup->database_type ?? 'unknown',
$server?->name ?? 'N/A',
$frequency,
$cacheValue ?? '<missing>',
$wouldFire ? 'YES' : 'no',
];
}
$this->table(
['Backup ID', 'DB Type', 'Server', 'Frequency', 'Cache Value', 'Would Fire'],
$rows
);
$this->newLine();
}
private function inspectTasks(): void
{
$this->info('=== Scheduled Tasks ===');
$tasks = ScheduledTask::with(['service', 'application'])
->where('enabled', true)
->get();
$rows = [];
foreach ($tasks as $task) {
$server = $task->server();
$frequency = $task->frequency;
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
$dedupKey = "scheduled-task:{$task->id}";
$cacheValue = Cache::get($dedupKey);
$timezone = $server ? data_get($server->settings, 'server_timezone', config('app.timezone')) : config('app.timezone');
if (validate_timezone($timezone) === false) {
$timezone = config('app.timezone');
}
$wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey);
$rows[] = [
$task->id,
$task->name,
$server?->name ?? 'N/A',
$frequency,
$cacheValue ?? '<missing>',
$wouldFire ? 'YES' : 'no',
];
}
$this->table(
['Task ID', 'Name', 'Server', 'Frequency', 'Cache Value', 'Would Fire'],
$rows
);
$this->newLine();
}
private function inspectServerJobs(?string $serverFilter): void
{
$this->info('=== Server Manager Jobs ===');
$servers = $this->getServers($serverFilter);
$rows = [];
foreach ($servers as $server) {
$timezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
if (validate_timezone($timezone) === false) {
$timezone = config('app.timezone');
}
$dedupKeys = [
"sentinel-restart:{$server->id}" => '0 0 * * *',
"server-patch-check:{$server->id}" => '0 0 * * 0',
"server-check:{$server->id}" => isCloud() ? '*/5 * * * *' : '* * * * *',
"server-storage-check:{$server->id}" => data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *'),
];
foreach ($dedupKeys as $dedupKey => $frequency) {
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
$cacheValue = Cache::get($dedupKey);
$wouldFire = shouldRunCronNow($frequency, $timezone, $dedupKey);
$rows[] = [
$server->id,
$server->name,
$dedupKey,
$frequency,
$cacheValue ?? '<missing>',
$wouldFire ? 'YES' : 'no',
];
}
}
$this->table(
['Server ID', 'Server', 'Dedup Key', 'Frequency', 'Cache Value', 'Would Fire'],
$rows
);
$this->newLine();
}
private function getServers(?string $serverFilter): \Illuminate\Support\Collection
{
$query = Server::with('settings')->where('ip', '!=', '1.2.3.4');
if ($serverFilter) {
$query->where('id', $serverFilter);
}
if (isCloud()) {
$servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
$own = Team::find(0)?->servers()->with('settings')->get() ?? collect();
return $servers->merge($own);
}
return $query->get();
}
}
-23
View File
@@ -1,23 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class Scheduler extends Command
{
protected $signature = 'start:scheduler';
protected $description = 'Start Scheduler';
public function handle()
{
if (config('constants.horizon.is_scheduler_enabled')) {
$this->info('Scheduler is enabled on this server.');
$this->call('schedule:work');
exit(0);
} else {
exit(0);
}
}
}
+305 -3
View File
@@ -363,6 +363,162 @@ class SyncBunny extends Command
}
}
/**
* Sync install.sh, docker-compose, and env files to GitHub repository via PR
*/
private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool
{
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$this->info("Syncing $envLabel files to GitHub repository...");
try {
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-files-'.$timestamp;
$branchName = 'update-files-'.$timestamp;
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Copy each file to its target path in the CDN repo
$copiedFiles = [];
foreach ($files as $sourceFile => $targetPath) {
if (! file_exists($sourceFile)) {
$this->warn("Source file not found, skipping: $sourceFile");
continue;
}
$destPath = "$tmpDir/$targetPath";
$destDir = dirname($destPath);
if (! is_dir($destDir)) {
if (! mkdir($destDir, 0755, true)) {
$this->error("Failed to create directory: $destDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
if (copy($sourceFile, $destPath) === false) {
$this->error("Failed to copy $sourceFile to $destPath");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
$copiedFiles[] = $targetPath;
$this->info("Copied: $targetPath");
}
if (empty($copiedFiles)) {
$this->warn('No files were copied. Nothing to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
// Stage all copied files
$this->info('Staging changes...');
$output = [];
$stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1';
exec($stageCmd, $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Check for changes
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('All files are already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
// Commit changes
$commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Create pull request
$this->info('Creating pull request...');
$prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s');
$fileList = implode("\n- ", $copiedFiles);
$prBody = "Automated update of $envLabel files:\n- $fileList";
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR URL: '.implode("\n", $output));
}
$this->info('Files synced: '.count($copiedFiles));
return true;
} catch (\Throwable $e) {
$this->error('Error syncing files to GitHub: '.$e->getMessage());
return false;
}
}
/**
* Sync versions.json to GitHub repository via PR
*/
@@ -581,11 +737,130 @@ class SyncBunny extends Command
$versions_location = "$parent_dir/other/nightly/$versions";
}
if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) {
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$this->info("About to sync $envLabel files to BunnyCDN and create a GitHub PR for coolify-cdn.");
$this->newLine();
// Build file mapping for diff
if ($nightly) {
$this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.');
$fileMapping = [
$compose_file_location => 'docker/nightly/docker-compose.yml',
$compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
$production_env_location => 'environment/nightly/.env.production',
$upgrade_script_location => 'scripts/nightly/upgrade.sh',
$install_script_location => 'scripts/nightly/install.sh',
];
} else {
$this->info('About to sync files PRODUCTION (docker-compose.yml, docker-compose.prod.yml, upgrade.sh, install.sh, etc) to BunnyCDN.');
$fileMapping = [
$compose_file_location => 'docker/docker-compose.yml',
$compose_file_prod_location => 'docker/docker-compose.prod.yml',
$production_env_location => 'environment/.env.production',
$upgrade_script_location => 'scripts/upgrade.sh',
$install_script_location => 'scripts/install.sh',
];
}
// BunnyCDN file mapping (local file => CDN URL path)
$bunnyFileMapping = [
$compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file",
$compose_file_prod_location => "$bunny_cdn/$bunny_cdn_path/$compose_file_prod",
$production_env_location => "$bunny_cdn/$bunny_cdn_path/$production_env",
$upgrade_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_script",
$install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script",
];
$diffTmpDir = sys_get_temp_dir().'/coolify-cdn-diff-'.time();
@mkdir($diffTmpDir, 0755, true);
$hasChanges = false;
// Diff against BunnyCDN
$this->info('Fetching files from BunnyCDN to compare...');
foreach ($bunnyFileMapping as $localFile => $cdnUrl) {
if (! file_exists($localFile)) {
$this->warn('Local file not found: '.$localFile);
continue;
}
$fileName = basename($cdnUrl);
$remoteTmp = "$diffTmpDir/bunny-$fileName";
try {
$response = Http::timeout(10)->get($cdnUrl);
if ($response->successful()) {
file_put_contents($remoteTmp, $response->body());
$diffOutput = [];
exec('diff -u '.escapeshellarg($remoteTmp).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode);
if ($diffCode !== 0) {
$hasChanges = true;
$this->newLine();
$this->info("--- BunnyCDN: $bunny_cdn_path/$fileName");
$this->info("+++ Local: $fileName");
foreach ($diffOutput as $line) {
if (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
continue;
}
$this->line($line);
}
}
} else {
$this->info("NEW on BunnyCDN: $bunny_cdn_path/$fileName (HTTP {$response->status()})");
$hasChanges = true;
}
} catch (\Throwable $e) {
$this->warn("Could not fetch $cdnUrl: {$e->getMessage()}");
}
}
// Diff against GitHub coolify-cdn repo
$this->newLine();
$this->info('Fetching coolify-cdn repo to compare...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg("$diffTmpDir/repo").' -- --depth 1 2>&1', $output, $returnCode);
if ($returnCode === 0) {
foreach ($fileMapping as $localFile => $cdnPath) {
$remotePath = "$diffTmpDir/repo/$cdnPath";
if (! file_exists($localFile)) {
continue;
}
if (! file_exists($remotePath)) {
$this->info("NEW on GitHub: $cdnPath (does not exist in coolify-cdn yet)");
$hasChanges = true;
continue;
}
$diffOutput = [];
exec('diff -u '.escapeshellarg($remotePath).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode);
if ($diffCode !== 0) {
$hasChanges = true;
$this->newLine();
$this->info("--- GitHub: $cdnPath");
$this->info("+++ Local: $cdnPath");
foreach ($diffOutput as $line) {
if (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
continue;
}
$this->line($line);
}
}
}
} else {
$this->warn('Could not fetch coolify-cdn repo for diff.');
}
exec('rm -rf '.escapeshellarg($diffTmpDir));
if (! $hasChanges) {
$this->newLine();
$this->info('No differences found. All files are already up to date.');
return;
}
$this->newLine();
$confirmed = confirm('Are you sure you want to sync?');
if (! $confirmed) {
return;
@@ -692,7 +967,34 @@ class SyncBunny extends Command
$pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_script"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"),
]);
$this->info('All files uploaded & purged...');
$this->info('All files uploaded & purged to BunnyCDN.');
$this->newLine();
// Sync files to GitHub CDN repository via PR
$this->info('Creating GitHub PR for coolify-cdn repository...');
if ($nightly) {
$files = [
$compose_file_location => 'docker/nightly/docker-compose.yml',
$compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
$production_env_location => 'environment/nightly/.env.production',
$upgrade_script_location => 'scripts/nightly/upgrade.sh',
$install_script_location => 'scripts/nightly/install.sh',
];
} else {
$files = [
$compose_file_location => 'docker/docker-compose.yml',
$compose_file_prod_location => 'docker/docker-compose.prod.yml',
$production_env_location => 'environment/.env.production',
$upgrade_script_location => 'scripts/upgrade.sh',
$install_script_location => 'scripts/install.sh',
];
}
$githubSuccess = $this->syncFilesToGitHubRepo($files, $nightly);
$this->newLine();
$this->info('=== Summary ===');
$this->info('BunnyCDN sync: Complete');
$this->info('GitHub PR: '.($githubSuccess ? 'Created' : 'Failed'));
} catch (\Throwable $e) {
$this->error('Error: '.$e->getMessage());
}
+1 -1
View File
@@ -40,7 +40,7 @@ class Kernel extends ConsoleKernel
}
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
$this->scheduleInstance->command('cleanup:redis')->weekly();
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
if (isDev()) {
// Instance Jobs
-30
View File
@@ -1,30 +0,0 @@
<?php
namespace App\Data;
use App\Enums\ProcessStatus;
use Illuminate\Database\Eloquent\Model;
use Spatie\LaravelData\Data;
/**
* The parameters to execute a CoolifyTask, organized in a DTO.
*/
class CoolifyTaskArgs extends Data
{
public function __construct(
public string $server_uuid,
public string $command,
public string $type,
public ?string $type_uuid = null,
public ?int $process_id = null,
public ?Model $model = null,
public ?string $status = null,
public bool $ignore_errors = false,
public $call_event_on_finish = null,
public $call_event_data = null
) {
if (is_null($status)) {
$this->status = ProcessStatus::QUEUED->value;
}
}
}
+43 -12
View File
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
class SshMultiplexingHelper
{
@@ -37,7 +38,7 @@ class SshMultiplexingHelper
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$checkCommand .= "{$server->user}@{$server->ip}";
$checkCommand .= self::escapedUserAtHost($server);
$process = Process::run($checkCommand);
if ($process->exitCode() !== 0) {
@@ -80,7 +81,7 @@ class SshMultiplexingHelper
$establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval);
$establishCommand .= "{$server->user}@{$server->ip}";
$establishCommand .= self::escapedUserAtHost($server);
$establishProcess = Process::run($establishCommand);
if ($establishProcess->exitCode() !== 0) {
return false;
@@ -101,7 +102,7 @@ class SshMultiplexingHelper
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$closeCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$closeCommand .= "{$server->user}@{$server->ip}";
$closeCommand .= self::escapedUserAtHost($server);
Process::run($closeCommand);
// Clear connection metadata from cache
@@ -141,9 +142,9 @@ class SshMultiplexingHelper
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true);
if ($server->isIpv6()) {
$scp_command .= "{$source} {$server->user}@[{$server->ip}]:{$dest}";
$scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}";
} else {
$scp_command .= "{$source} {$server->user}@{$server->ip}:{$dest}";
$scp_command .= "{$source} ".self::escapedUserAtHost($server).":{$dest}";
}
return $scp_command;
@@ -189,13 +190,18 @@ class SshMultiplexingHelper
$delimiter = base64_encode($delimiter);
$command = str_replace($delimiter, '', $command);
$ssh_command .= "{$server->user}@{$server->ip} 'bash -se' << \\$delimiter".PHP_EOL
$ssh_command .= self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
.$command.PHP_EOL
.$delimiter;
return $ssh_command;
}
private static function escapedUserAtHost(Server $server): string
{
return escapeshellarg($server->user).'@'.escapeshellarg($server->ip);
}
private static function isMultiplexingEnabled(): bool
{
return config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop');
@@ -204,12 +210,37 @@ class SshMultiplexingHelper
private static function validateSshKey(PrivateKey $privateKey): void
{
$keyLocation = $privateKey->getKeyLocation();
$checkKeyCommand = "ls $keyLocation 2>/dev/null";
$keyCheckProcess = Process::run($checkKeyCommand);
$filename = "ssh_key@{$privateKey->uuid}";
$disk = Storage::disk('ssh-keys');
if ($keyCheckProcess->exitCode() !== 0) {
$needsRewrite = false;
if (! $disk->exists($filename)) {
$needsRewrite = true;
} else {
$diskContent = $disk->get($filename);
if ($diskContent !== $privateKey->private_key) {
Log::warning('SSH key file content does not match database, resyncing', [
'key_uuid' => $privateKey->uuid,
]);
$needsRewrite = true;
}
}
if ($needsRewrite) {
$privateKey->storeInFileSystem();
}
// Ensure correct permissions (SSH requires 0600)
if (file_exists($keyLocation)) {
$currentPerms = fileperms($keyLocation) & 0777;
if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) {
Log::warning('Failed to set SSH key file permissions to 0600', [
'key_uuid' => $privateKey->uuid,
'path' => $keyLocation,
]);
}
}
}
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
@@ -224,9 +255,9 @@ class SshMultiplexingHelper
// Bruh
if ($isScp) {
$options .= "-P {$server->port} ";
$options .= '-P '.escapeshellarg((string) $server->port).' ';
} else {
$options .= "-p {$server->port} ";
$options .= '-p '.escapeshellarg((string) $server->port).' ';
}
return $options;
@@ -245,7 +276,7 @@ class SshMultiplexingHelper
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$healthCommand .= "{$server->user}@{$server->ip} 'echo \"health_check_ok\"'";
$healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
$process = Process::run($healthCommand);
$isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
@@ -11,6 +11,8 @@ use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
@@ -18,9 +20,11 @@ use App\Models\Service;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser;
use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use OpenApi\Attributes as OA;
use Spatie\Url\Url;
@@ -1002,10 +1006,10 @@ class ApplicationsController extends Controller
$this->authorize('create', Application::class);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@@ -1099,13 +1103,23 @@ class ApplicationsController extends Controller
return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400);
}
$destination = $destinations->first();
if ($destinations->count() > 1 && $request->has('destination_uuid')) {
$destination = $destinations->where('uuid', $request->destination_uuid)->first();
if (! $destination) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.',
],
], 422);
}
}
if ($type === 'public') {
$validationRules = [
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@@ -1141,14 +1155,14 @@ class ApplicationsController extends Controller
$request->offsetSet('name', generate_application_name($request->git_repository, $request->git_branch));
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$application = new Application;
removeUnnecessaryFieldsFromRequest($request);
$application->fill($request->all());
$application->fill($request->only($allowedFields));
$dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) {
$dockerComposeDomains = collect($request->docker_compose_domains);
@@ -1305,7 +1319,6 @@ class ApplicationsController extends Controller
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'github_app_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@@ -1341,7 +1354,7 @@ class ApplicationsController extends Controller
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$githubApp = GithubApp::whereTeamId($teamId)->where('uuid', $githubAppUuid)->first();
@@ -1380,7 +1393,7 @@ class ApplicationsController extends Controller
$application = new Application;
removeUnnecessaryFieldsFromRequest($request);
$application->fill($request->all());
$application->fill($request->only($allowedFields));
$dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) {
@@ -1537,7 +1550,6 @@ class ApplicationsController extends Controller
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'private_key_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@@ -1574,7 +1586,7 @@ class ApplicationsController extends Controller
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$privateKey = PrivateKey::whereTeamId($teamId)->where('uuid', $request->private_key_uuid)->first();
@@ -1585,7 +1597,7 @@ class ApplicationsController extends Controller
$application = new Application;
removeUnnecessaryFieldsFromRequest($request);
$application->fill($request->all());
$application->fill($request->only($allowedFields));
$dockerComposeDomainsJson = collect();
if ($request->has('docker_compose_domains')) {
@@ -1747,7 +1759,7 @@ class ApplicationsController extends Controller
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
if (! isBase64Encoded($request->dockerfile)) {
@@ -1776,7 +1788,7 @@ class ApplicationsController extends Controller
}
$application = new Application;
$application->fill($request->all());
$application->fill($request->only($allowedFields));
$application->fqdn = $fqdn;
$application->ports_exposes = $port;
$application->build_pack = 'dockerfile';
@@ -1855,7 +1867,7 @@ class ApplicationsController extends Controller
$request->offsetSet('name', 'docker-image-'.new Cuid2);
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
// Process docker image name and tag using DockerImageParser
@@ -1888,7 +1900,7 @@ class ApplicationsController extends Controller
$application = new Application;
removeUnnecessaryFieldsFromRequest($request);
$application->fill($request->all());
$application->fill($request->only($allowedFields));
$application->fqdn = $fqdn;
$application->build_pack = 'dockerimage';
$application->destination_id = $destination->id;
@@ -1979,7 +1991,7 @@ class ApplicationsController extends Controller
], 422);
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
if (! isBase64Encoded($request->docker_compose_raw)) {
@@ -2004,7 +2016,7 @@ class ApplicationsController extends Controller
$service = new Service;
removeUnnecessaryFieldsFromRequest($request);
$service->fill($request->all());
$service->fill($request->only($allowedFields));
$service->docker_compose_raw = $dockerComposeRaw;
$service->environment_id = $environment->id;
@@ -2466,7 +2478,7 @@ class ApplicationsController extends Controller
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@@ -2480,20 +2492,17 @@ class ApplicationsController extends Controller
$this->authorize('update', $application);
$server = $application->destination->server;
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
$validationRules = [
'name' => 'string|max:255',
'description' => 'string|nullable',
'static_image' => 'string',
'watch_paths' => 'string|nullable',
'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_custom_start_command' => 'string|nullable',
'docker_compose_custom_build_command' => 'string|nullable',
'custom_nginx_configuration' => 'string|nullable',
'is_http_basic_auth_enabled' => 'boolean|nullable',
'http_basic_auth_username' => 'string',
@@ -2539,7 +2548,7 @@ class ApplicationsController extends Controller
}
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -2771,7 +2780,7 @@ class ApplicationsController extends Controller
}
removeUnnecessaryFieldsFromRequest($request);
$data = $request->all();
$data = $request->only($allowedFields);
if ($requestHasDomains && $server->isProxyShouldRun()) {
data_set($data, 'fqdn', $domains);
}
@@ -2939,10 +2948,7 @@ class ApplicationsController extends Controller
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Environment variable updated.'],
]
ref: '#/components/schemas/EnvironmentVariable'
)
),
]
@@ -2963,7 +2969,7 @@ class ApplicationsController extends Controller
)]
public function update_env_by_uuid(Request $request)
{
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime'];
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -2971,10 +2977,10 @@ class ApplicationsController extends Controller
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@@ -2993,6 +2999,7 @@ class ApplicationsController extends Controller
'is_shown_once' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -3034,6 +3041,9 @@ class ApplicationsController extends Controller
if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
$env->is_buildtime = $request->is_buildtime;
}
if ($request->has('comment') && $env->comment != $request->comment) {
$env->comment = $request->comment;
}
$env->save();
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
@@ -3064,6 +3074,9 @@ class ApplicationsController extends Controller
if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
$env->is_buildtime = $request->is_buildtime;
}
if ($request->has('comment') && $env->comment != $request->comment) {
$env->comment = $request->comment;
}
$env->save();
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
@@ -3165,10 +3178,10 @@ class ApplicationsController extends Controller
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@@ -3185,7 +3198,7 @@ class ApplicationsController extends Controller
], 400);
}
$bulk_data = collect($bulk_data)->map(function ($item) {
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']);
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']);
});
$returnedEnvs = collect();
foreach ($bulk_data as $item) {
@@ -3198,6 +3211,7 @@ class ApplicationsController extends Controller
'is_shown_once' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
return response()->json([
@@ -3230,6 +3244,9 @@ class ApplicationsController extends Controller
if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
$env->is_buildtime = $item->get('is_buildtime');
}
if ($item->has('comment') && $env->comment != $item->get('comment')) {
$env->comment = $item->get('comment');
}
$env->save();
} else {
$env = $application->environment_variables()->create([
@@ -3241,6 +3258,7 @@ class ApplicationsController extends Controller
'is_shown_once' => $is_shown_once,
'is_runtime' => $item->get('is_runtime', true),
'is_buildtime' => $item->get('is_buildtime', true),
'comment' => $item->get('comment'),
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -3264,6 +3282,9 @@ class ApplicationsController extends Controller
if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
$env->is_buildtime = $item->get('is_buildtime');
}
if ($item->has('comment') && $env->comment != $item->get('comment')) {
$env->comment = $item->get('comment');
}
$env->save();
} else {
$env = $application->environment_variables()->create([
@@ -3275,6 +3296,7 @@ class ApplicationsController extends Controller
'is_shown_once' => $is_shown_once,
'is_runtime' => $item->get('is_runtime', true),
'is_buildtime' => $item->get('is_buildtime', true),
'comment' => $item->get('comment'),
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -3356,13 +3378,13 @@ class ApplicationsController extends Controller
)]
public function create_env(Request $request)
{
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime'];
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@@ -3381,6 +3403,7 @@ class ApplicationsController extends Controller
'is_shown_once' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -3416,6 +3439,7 @@ class ApplicationsController extends Controller
'is_shown_once' => $request->is_shown_once ?? false,
'is_runtime' => $request->is_runtime ?? true,
'is_buildtime' => $request->is_buildtime ?? true,
'comment' => $request->comment ?? null,
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -3440,6 +3464,7 @@ class ApplicationsController extends Controller
'is_shown_once' => $request->is_shown_once ?? false,
'is_runtime' => $request->is_runtime ?? true,
'is_buildtime' => $request->is_buildtime ?? true,
'comment' => $request->comment ?? null,
'resourceable_type' => get_class($application),
'resourceable_id' => $application->id,
]);
@@ -3516,7 +3541,7 @@ class ApplicationsController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
@@ -3526,7 +3551,7 @@ class ApplicationsController extends Controller
$this->authorize('manageEnvironment', $application);
$found_env = EnvironmentVariable::where('uuid', $request->env_uuid)
$found_env = EnvironmentVariable::where('uuid', $request->route('env_uuid'))
->where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->first();
@@ -3676,6 +3701,15 @@ class ApplicationsController extends Controller
type: 'string',
)
),
new OA\Parameter(
name: 'docker_cleanup',
in: 'query',
description: 'Perform docker cleanup (prune networks, volumes, etc.).',
schema: new OA\Schema(
type: 'boolean',
default: true,
)
),
],
responses: [
new OA\Response(
@@ -3724,7 +3758,8 @@ class ApplicationsController extends Controller
$this->authorize('deploy', $application);
StopApplication::dispatch($application);
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopApplication::dispatch($application, false, $dockerCleanup);
return response()->json(
[
@@ -3915,4 +3950,528 @@ class ApplicationsController extends Controller
}
}
}
#[OA\Get(
summary: 'List Storages',
description: 'List all persistent storages and file storages by application UUID.',
path: '/applications/{uuid}/storages',
operationId: 'list-storages-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'All storages by application UUID.',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')),
new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')),
],
),
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function storages(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) {
return response()->json([
'message' => 'Application not found',
], 404);
}
$this->authorize('view', $application);
$persistentStorages = $application->persistentStorages->sortBy('id')->values();
$fileStorages = $application->fileStorages->sortBy('id')->values();
return response()->json([
'persistent_storages' => $persistentStorages,
'file_storages' => $fileStorages,
]);
}
#[OA\Patch(
summary: 'Update Storage',
description: 'Update a persistent storage or file storage by application UUID.',
path: '/applications/{uuid}/storages',
operationId: 'update-storage-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['type'],
properties: [
'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'],
'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'],
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'],
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'],
],
additionalProperties: false,
),
),
],
),
responses: [
new OA\Response(
response: 200,
description: 'Storage updated.',
content: new OA\JsonContent(type: 'object'),
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
if (! $application) {
return response()->json([
'message' => 'Application not found',
], 404);
}
$this->authorize('update', $application);
$validator = customApiValidator($request->all(), [
'uuid' => 'string',
'id' => 'integer',
'type' => 'required|string|in:persistent,file',
'is_preview_suffix_enabled' => 'boolean',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
]);
$allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$storageUuid = $request->input('uuid');
$storageId = $request->input('id');
if (! $storageUuid && ! $storageId) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['uuid' => 'Either uuid or id is required.'],
], 422);
}
$lookupField = $storageUuid ? 'uuid' : 'id';
$lookupValue = $storageUuid ?? $storageId;
if ($request->type === 'persistent') {
$storage = $application->persistentStorages->where($lookupField, $lookupValue)->first();
} else {
$storage = $application->fileStorages->where($lookupField, $lookupValue)->first();
}
if (! $storage) {
return response()->json([
'message' => 'Storage not found.',
], 404);
}
$isReadOnly = $storage->shouldBeReadOnlyInUI();
$editableOnlyFields = ['name', 'mount_path', 'host_path', 'content'];
$requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all()));
if ($isReadOnly && ! empty($requestedEditableFields)) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.',
'read_only_fields' => array_values($requestedEditableFields),
], 422);
}
// Reject fields that don't apply to the given storage type
if (! $isReadOnly) {
$typeSpecificInvalidFields = $request->type === 'persistent'
? array_intersect(['content'], array_keys($request->all()))
: array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]),
], 422);
}
}
// Always allowed
if ($request->has('is_preview_suffix_enabled')) {
$storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
}
// Only for editable storages
if (! $isReadOnly) {
if ($request->type === 'persistent') {
if ($request->has('name')) {
$storage->name = $request->name;
}
if ($request->has('mount_path')) {
$storage->mount_path = $request->mount_path;
}
if ($request->has('host_path')) {
$storage->host_path = $request->host_path;
}
} else {
if ($request->has('mount_path')) {
$storage->mount_path = $request->mount_path;
}
if ($request->has('content')) {
$storage->content = $request->content;
}
}
}
$storage->save();
return response()->json($storage);
}
#[OA\Post(
summary: 'Create Storage',
description: 'Create a persistent storage or file storage for an application.',
path: '/applications/{uuid}/storages',
operationId: 'create-storage-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
requestBody: new OA\RequestBody(
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['type', 'mount_path'],
properties: [
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'],
'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
],
additionalProperties: false,
),
),
],
),
responses: [
new OA\Response(
response: 201,
description: 'Storage created.',
content: new OA\JsonContent(type: 'object'),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function create_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
$this->authorize('update', $application);
$validator = customApiValidator($request->all(), [
'type' => 'required|string|in:persistent,file',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
if ($request->type === 'persistent') {
if (! $request->name) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['name' => 'The name field is required for persistent storages.'],
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'persistent'."]),
], 422);
}
$storage = LocalPersistentVolume::create([
'name' => $application->uuid.'-'.$request->name,
'mount_path' => $request->mount_path,
'host_path' => $request->host_path,
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
return response()->json($storage, 201);
}
// File storage
$typeSpecificInvalidFields = array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'file'."]),
], 422);
}
$isDirectory = $request->boolean('is_directory', false);
if ($isDirectory) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'],
], 422);
}
$fsPath = str($request->fs_path)->trim()->start('/')->value();
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($fsPath, 'storage source path');
validateShellSafePath($mountPath, 'storage destination path');
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'is_directory' => true,
'resource_id' => $application->id,
'resource_type' => get_class($application),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($mountPath, 'file storage path');
$fsPath = application_configuration_dir().'/'.$application->uuid.$mountPath;
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => $request->content,
'is_directory' => false,
'resource_id' => $application->id,
'resource_type' => get_class($application),
]);
}
return response()->json($storage, 201);
}
#[OA\Delete(
summary: 'Delete Storage',
description: 'Delete a persistent storage or file storage by application UUID.',
path: '/applications/{uuid}/storages/{storage_uuid}',
operationId: 'delete-storage-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'storage_uuid',
in: 'path',
description: 'UUID of the storage.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent(
properties: [new OA\Property(property: 'message', type: 'string')],
)),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function delete_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
$this->authorize('update', $application);
$storageUuid = $request->route('storage_uuid');
$storage = $application->persistentStorages->where('uuid', $storageUuid)->first();
if (! $storage) {
$storage = $application->fileStorages->where('uuid', $storageUuid)->first();
}
if (! $storage) {
return response()->json(['message' => 'Storage not found.'], 404);
}
if ($storage->shouldBeReadOnlyInUI()) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.',
], 422);
}
if ($storage instanceof LocalFileVolume) {
$storage->deleteStorageOnServer();
}
$storage->delete();
return response()->json(['message' => 'Storage deleted.']);
}
}
File diff suppressed because it is too large. Load diff
@@ -127,6 +127,10 @@ class DeployController extends Controller
if (! $deployment) {
return response()->json(['message' => 'Deployment not found.'], 404);
}
$application = $deployment->application;
if (! $application || data_get($application->team(), 'id') !== (int) $teamId) {
return response()->json(['message' => 'Deployment not found.'], 404);
}
return response()->json($this->removeSensitiveData($deployment));
}
@@ -246,7 +250,7 @@ class DeployController extends Controller
]);
// Get the server
$server = Server::find($build_server_id);
$server = Server::whereTeamId($teamId)->find($build_server_id);
if ($server) {
// Add cancellation log entry
+12 -9
View File
@@ -5,6 +5,9 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Rules\SafeExternalUrl;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
@@ -181,7 +184,7 @@ class GithubController extends Controller
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@@ -204,8 +207,8 @@ class GithubController extends Controller
$validator = customApiValidator($request->all(), [
'name' => 'required|string|max:255',
'organization' => 'nullable|string|max:255',
'api_url' => 'required|string|url',
'html_url' => 'required|string|url',
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
'custom_user' => 'nullable|string|max:255',
'custom_port' => 'nullable|integer|min:1|max:65535',
'app_id' => 'required|integer',
@@ -370,7 +373,7 @@ class GithubController extends Controller
return response()->json([
'repositories' => $repositories->sortBy('name')->values(),
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
} catch (ModelNotFoundException $e) {
return response()->json(['message' => 'GitHub app not found'], 404);
} catch (\Throwable $e) {
return handleError($e);
@@ -472,7 +475,7 @@ class GithubController extends Controller
return response()->json([
'branches' => $branches,
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
} catch (ModelNotFoundException $e) {
return response()->json(['message' => 'GitHub app not found'], 404);
} catch (\Throwable $e) {
return handleError($e);
@@ -587,10 +590,10 @@ class GithubController extends Controller
$rules['organization'] = 'nullable|string';
}
if (isset($payload['api_url'])) {
$rules['api_url'] = 'url';
$rules['api_url'] = ['url', new SafeExternalUrl];
}
if (isset($payload['html_url'])) {
$rules['html_url'] = 'url';
$rules['html_url'] = ['url', new SafeExternalUrl];
}
if (isset($payload['custom_user'])) {
$rules['custom_user'] = 'string';
@@ -651,7 +654,7 @@ class GithubController extends Controller
'message' => 'GitHub app updated successfully',
'data' => $githubApp,
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
} catch (ModelNotFoundException $e) {
return response()->json([
'message' => 'GitHub app not found',
], 404);
@@ -736,7 +739,7 @@ class GithubController extends Controller
return response()->json([
'message' => 'GitHub app deleted successfully',
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
} catch (ModelNotFoundException $e) {
return response()->json([
'message' => 'GitHub app not found',
], 404);
@@ -586,7 +586,8 @@ class HetznerController extends Controller
}
// Check server limit
if (Team::serverLimitReached()) {
$team = Team::find($teamId);
if (Team::serverLimitReached($team)) {
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
}
@@ -0,0 +1,922 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\ScheduledTask;
use App\Models\Service;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class ScheduledTasksController extends Controller
{
private function removeSensitiveData($task)
{
$task->makeHidden([
'id',
'team_id',
'application_id',
'service_id',
]);
return serializeApiResponse($task);
}
private function resolveApplication(Request $request, int $teamId): ?Application
{
return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
}
private function resolveService(Request $request, int $teamId): ?Service
{
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
}
private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('view', $resource);
$tasks = $resource->scheduled_tasks->map(function ($task) {
return $this->removeSensitiveData($task);
});
return response()->json($tasks);
}
private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'required|string|max:255',
'command' => 'required|string',
'frequency' => 'required|string',
'container' => 'string|nullable',
'timeout' => 'integer|min:1',
'enabled' => 'boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
if (! validate_cron_expression($request->frequency)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
], 422);
}
$teamId = getTeamIdFromToken();
$task = new ScheduledTask;
$task->name = $request->name;
$task->command = $request->command;
$task->frequency = $request->frequency;
$task->container = $request->container;
$task->timeout = $request->has('timeout') ? $request->timeout : 300;
$task->enabled = $request->has('enabled') ? $request->enabled : true;
$task->team_id = $teamId;
if ($resource instanceof Application) {
$task->application_id = $resource->id;
} elseif ($resource instanceof Service) {
$task->service_id = $resource->id;
}
$task->save();
return response()->json($this->removeSensitiveData($task), 201);
}
private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
if ($request->all() === []) {
return response()->json(['message' => 'At least one field must be provided.'], 422);
}
$allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
'command' => 'string',
'frequency' => 'string',
'container' => 'string|nullable',
'timeout' => 'integer|min:1',
'enabled' => 'boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
if ($request->has('frequency') && ! validate_cron_expression($request->frequency)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
], 422);
}
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
if (! $task) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
$task->update($request->only($allowedFields));
return response()->json($this->removeSensitiveData($task), 200);
}
private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('update', $resource);
$deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete();
if (! $deleted) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
return response()->json(['message' => 'Scheduled task deleted.']);
}
private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('view', $resource);
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
if (! $task) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
$executions = $task->executions()->get()->map(function ($execution) {
$execution->makeHidden(['id', 'scheduled_task_id']);
return serializeApiResponse($execution);
});
return response()->json($executions);
}
#[OA\Get(
summary: 'List Tasks',
description: 'List all scheduled tasks for an application.',
path: '/applications/{uuid}/scheduled-tasks',
operationId: 'list-scheduled-tasks-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all scheduled tasks for an application.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/ScheduledTask')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->listTasks($application);
}
#[OA\Post(
summary: 'Create Task',
description: 'Create a new scheduled task for an application.',
path: '/applications/{uuid}/scheduled-tasks',
operationId: 'create-scheduled-task-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Scheduled task data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['name', 'command', 'frequency'],
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
],
),
)
),
responses: [
new OA\Response(
response: 201,
description: 'Scheduled task created.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->createTask($request, $application);
}
#[OA\Patch(
summary: 'Update Task',
description: 'Update a scheduled task for an application.',
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}',
operationId: 'update-scheduled-task-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Scheduled task data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
],
),
)
),
responses: [
new OA\Response(
response: 200,
description: 'Scheduled task updated.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->updateTask($request, $application);
}
#[OA\Delete(
summary: 'Delete Task',
description: 'Delete a scheduled task for an application.',
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}',
operationId: 'delete-scheduled-task-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Scheduled task deleted.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->deleteTask($request, $application);
}
#[OA\Get(
summary: 'List Executions',
description: 'List all executions for a scheduled task on an application.',
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}/executions',
operationId: 'list-scheduled-task-executions-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all executions for a scheduled task.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->getExecutions($request, $application);
}
#[OA\Get(
summary: 'List Tasks',
description: 'List all scheduled tasks for a service.',
path: '/services/{uuid}/scheduled-tasks',
operationId: 'list-scheduled-tasks-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all scheduled tasks for a service.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/ScheduledTask')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->listTasks($service);
}
#[OA\Post(
summary: 'Create Task',
description: 'Create a new scheduled task for a service.',
path: '/services/{uuid}/scheduled-tasks',
operationId: 'create-scheduled-task-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Scheduled task data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['name', 'command', 'frequency'],
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
],
),
)
),
responses: [
new OA\Response(
response: 201,
description: 'Scheduled task created.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->createTask($request, $service);
}
#[OA\Patch(
summary: 'Update Task',
description: 'Update a scheduled task for a service.',
path: '/services/{uuid}/scheduled-tasks/{task_uuid}',
operationId: 'update-scheduled-task-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Scheduled task data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
],
),
)
),
responses: [
new OA\Response(
response: 200,
description: 'Scheduled task updated.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->updateTask($request, $service);
}
#[OA\Delete(
summary: 'Delete Task',
description: 'Delete a scheduled task for a service.',
path: '/services/{uuid}/scheduled-tasks/{task_uuid}',
operationId: 'delete-scheduled-task-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Scheduled task deleted.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->deleteTask($request, $service);
}
#[OA\Get(
summary: 'List Executions',
description: 'List all executions for a scheduled task on a service.',
path: '/services/{uuid}/scheduled-tasks/{task_uuid}/executions',
operationId: 'list-scheduled-task-executions-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all executions for a scheduled task.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->getExecutions($request, $service);
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\PrivateKey;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -176,7 +177,7 @@ class SecurityController extends Controller
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -300,7 +301,7 @@ class SecurityController extends Controller
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@@ -330,7 +331,7 @@ class SecurityController extends Controller
'message' => 'Private Key not found.',
], 404);
}
$foundKey->update($request->all());
$foundKey->update($request->only($allowedFields));
return response()->json(serializeApiResponse([
'uuid' => $foundKey->uuid,
+47 -19
View File
@@ -7,10 +7,12 @@ use App\Actions\Server\ValidateServer;
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server as ModelsServer;
use App\Rules\ValidServerIp;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
use Stringable;
@@ -288,15 +290,24 @@ class ServersController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuid = $request->get('uuid');
$server = ModelsServer::whereTeamId($teamId)->whereUuid($request->uuid)->first();
if (is_null($server)) {
return response()->json(['message' => 'Server not found.'], 404);
}
$uuid = $request->query('uuid');
if ($uuid) {
$domains = Application::getDomainsByUuid($uuid);
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return response()->json(serializeApiResponse($domains));
return response()->json(serializeApiResponse($application->fqdns));
}
$projects = Project::where('team_id', $teamId)->get();
$domains = collect();
$applications = $projects->pluck('applications')->flatten();
$applications = $projects->pluck('applications')->flatten()->filter(function ($application) use ($server) {
return $application->destination?->server?->id === $server->id;
});
$settings = instanceSettings();
if ($applications->count() > 0) {
foreach ($applications as $application) {
@@ -336,7 +347,9 @@ class ServersController extends Controller
}
}
}
$services = $projects->pluck('services')->flatten();
$services = $projects->pluck('services')->flatten()->filter(function ($service) use ($server) {
return $service->server_id === $server->id;
});
if ($services->count() > 0) {
foreach ($services as $service) {
$service_applications = $service->applications;
@@ -349,7 +362,8 @@ class ServersController extends Controller
})->filter(function (Stringable $fqdn) {
return $fqdn->isNotEmpty();
});
if ($ip === 'host.docker.internal') {
$serviceIp = $server->ip;
if ($serviceIp === 'host.docker.internal') {
if ($settings->public_ipv4) {
$domains->push([
'domain' => $fqdn,
@@ -365,13 +379,13 @@ class ServersController extends Controller
if (! $settings->public_ipv4 && ! $settings->public_ipv6) {
$domains->push([
'domain' => $fqdn,
'ip' => $ip,
'ip' => $serviceIp,
]);
}
} else {
$domains->push([
'domain' => $fqdn,
'ip' => $ip,
'ip' => $serviceIp,
]);
}
}
@@ -469,10 +483,10 @@ class ServersController extends Controller
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
'description' => 'string|nullable',
'ip' => 'string|required',
'port' => 'integer|nullable',
'ip' => ['string', 'required', new ValidServerIp],
'port' => 'integer|nullable|between:1,65535',
'private_key_uuid' => 'string|required',
'user' => 'string|nullable',
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
'is_build_server' => 'boolean|nullable',
'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable',
@@ -519,9 +533,13 @@ class ServersController extends Controller
if (! $privateKey) {
return response()->json(['message' => 'Private key not found.'], 404);
}
$allServers = ModelsServer::whereIp($request->ip)->get();
if ($allServers->count() > 0) {
return response()->json(['message' => 'Server with this IP already exists.'], 400);
$foundServer = ModelsServer::whereIp($request->ip)->first();
if ($foundServer) {
if ($foundServer->team_id === $teamId) {
return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400);
}
return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400);
}
$proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value;
@@ -630,10 +648,10 @@ class ServersController extends Controller
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255|nullable',
'description' => 'string|nullable',
'ip' => 'string|nullable',
'port' => 'integer|nullable',
'ip' => ['string', 'nullable', new ValidServerIp],
'port' => 'integer|nullable|between:1,65535',
'private_key_uuid' => 'string|nullable',
'user' => 'string|nullable',
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
'is_build_server' => 'boolean|nullable',
'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable',
@@ -750,12 +768,22 @@ class ServersController extends Controller
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
if ($server->definedResources()->count() > 0) {
return response()->json(['message' => 'Server has resources, so you need to delete them before.'], 400);
$force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN);
if ($server->definedResources()->count() > 0 && ! $force) {
return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400);
}
if ($server->isLocalhost()) {
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
}
if ($force) {
foreach ($server->definedResources() as $resource) {
DeleteResourceJob::dispatch($resource);
}
}
$server->delete();
DeleteServer::dispatch(
$server->id,
+694 -21
View File
@@ -8,9 +8,13 @@ use App\Actions\Service\StopService;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Models\EnvironmentVariable;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
@@ -222,6 +226,7 @@ class ServicesController extends Controller
),
],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
],
),
),
@@ -288,7 +293,7 @@ class ServicesController extends Controller
)]
public function create_service(Request $request)
{
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override'];
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -298,7 +303,7 @@ class ServicesController extends Controller
$this->authorize('create', Service::class);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$validationRules = [
@@ -317,6 +322,7 @@ class ServicesController extends Controller
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -377,6 +383,17 @@ class ServicesController extends Controller
return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400);
}
$destination = $destinations->first();
if ($destinations->count() > 1 && $request->has('destination_uuid')) {
$destination = $destinations->where('uuid', $request->destination_uuid)->first();
if (! $destination) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.',
],
], 422);
}
}
$services = get_service_templates();
$serviceKeys = $services->keys();
if ($serviceKeys->contains($request->type)) {
@@ -418,6 +435,9 @@ class ServicesController extends Controller
$service = Service::create($servicePayload);
$service->name = $request->name ?? "$oneClickServiceName-".$service->uuid;
$service->description = $request->description;
if ($request->has('is_container_label_escape_enabled')) {
$service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
}
$service->save();
if ($oneClickDotEnvs?->count() > 0) {
$oneClickDotEnvs->each(function ($value) use ($service) {
@@ -474,7 +494,7 @@ class ServicesController extends Controller
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
} elseif (filled($request->docker_compose_raw)) {
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override'];
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'project_uuid' => 'string|required',
@@ -492,6 +512,7 @@ class ServicesController extends Controller
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -543,6 +564,17 @@ class ServicesController extends Controller
return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400);
}
$destination = $destinations->first();
if ($destinations->count() > 1 && $request->has('destination_uuid')) {
$destination = $destinations->where('uuid', $request->destination_uuid)->first();
if (! $destination) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.',
],
], 422);
}
}
if (! isBase64Encoded($request->docker_compose_raw)) {
return response()->json([
'message' => 'Validation failed.',
@@ -587,6 +619,9 @@ class ServicesController extends Controller
$service->destination_id = $destination->id;
$service->destination_type = $destination->getMorphClass();
$service->connect_to_docker_network = $connectToDockerNetwork;
if ($request->has('is_container_label_escape_enabled')) {
$service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
}
$service->save();
$service->parse(isNew: true);
@@ -813,6 +848,7 @@ class ServicesController extends Controller
),
],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
],
)
),
@@ -890,7 +926,7 @@ class ServicesController extends Controller
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@@ -901,7 +937,7 @@ class ServicesController extends Controller
$this->authorize('update', $service);
$allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override'];
$allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'name' => 'string|max:255',
@@ -914,6 +950,7 @@ class ServicesController extends Controller
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -979,6 +1016,9 @@ class ServicesController extends Controller
if ($request->has('connect_to_docker_network')) {
$service->connect_to_docker_network = $request->connect_to_docker_network;
}
if ($request->has('is_container_label_escape_enabled')) {
$service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
}
$service->save();
$service->parse();
@@ -1141,10 +1181,7 @@ class ServicesController extends Controller
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Environment variable updated.'],
]
ref: '#/components/schemas/EnvironmentVariable'
)
),
]
@@ -1174,7 +1211,7 @@ class ServicesController extends Controller
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
@@ -1187,6 +1224,7 @@ class ServicesController extends Controller
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
@@ -1202,7 +1240,19 @@ class ServicesController extends Controller
return response()->json(['message' => 'Environment variable not found.'], 404);
}
$env->fill($request->all());
$env->value = $request->value;
if ($request->has('is_literal')) {
$env->is_literal = $request->is_literal;
}
if ($request->has('is_multiline')) {
$env->is_multiline = $request->is_multiline;
}
if ($request->has('is_shown_once')) {
$env->is_shown_once = $request->is_shown_once;
}
if ($request->has('comment')) {
$env->comment = $request->comment;
}
$env->save();
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
@@ -1265,10 +1315,8 @@ class ServicesController extends Controller
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Environment variables updated.'],
]
type: 'array',
items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable')
)
),
]
@@ -1298,7 +1346,7 @@ class ServicesController extends Controller
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
@@ -1318,6 +1366,7 @@ class ServicesController extends Controller
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
@@ -1417,7 +1466,7 @@ class ServicesController extends Controller
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
@@ -1430,6 +1479,7 @@ class ServicesController extends Controller
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
@@ -1447,7 +1497,14 @@ class ServicesController extends Controller
], 409);
}
$env = $service->environment_variables()->create($request->all());
$env = $service->environment_variables()->create([
'key' => $key,
'value' => $request->value,
'is_literal' => $request->is_literal ?? false,
'is_multiline' => $request->is_multiline ?? false,
'is_shown_once' => $request->is_shown_once ?? false,
'comment' => $request->comment ?? null,
]);
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
}
@@ -1518,14 +1575,14 @@ class ServicesController extends Controller
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('manageEnvironment', $service);
$env = EnvironmentVariable::where('uuid', $request->env_uuid)
$env = EnvironmentVariable::where('uuid', $request->route('env_uuid'))
->where('resourceable_type', Service::class)
->where('resourceable_id', $service->id)
->first();
@@ -1638,6 +1695,15 @@ class ServicesController extends Controller
type: 'string',
)
),
new OA\Parameter(
name: 'docker_cleanup',
in: 'query',
description: 'Perform docker cleanup (prune networks, volumes, etc.).',
schema: new OA\Schema(
type: 'boolean',
default: true,
)
),
],
responses: [
new OA\Response(
@@ -1689,7 +1755,9 @@ class ServicesController extends Controller
if (str($service->status)->contains('stopped') || str($service->status)->contains('exited')) {
return response()->json(['message' => 'Service is already stopped.'], 400);
}
StopService::dispatch($service);
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopService::dispatch($service, false, $dockerCleanup);
return response()->json(
[
@@ -1785,4 +1853,609 @@ class ServicesController extends Controller
200
);
}
#[OA\Get(
summary: 'List Storages',
description: 'List all persistent storages and file storages by service UUID.',
path: '/services/{uuid}/storages',
operationId: 'list-storages-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'All storages by service UUID.',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')),
new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')),
],
),
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function storages(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
if (! $service) {
return response()->json([
'message' => 'Service not found.',
], 404);
}
$this->authorize('view', $service);
$persistentStorages = collect();
$fileStorages = collect();
foreach ($service->applications as $app) {
$persistentStorages = $persistentStorages->merge(
$app->persistentStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $app->uuid)->setAttribute('resource_type', 'application'))
);
$fileStorages = $fileStorages->merge(
$app->fileStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $app->uuid)->setAttribute('resource_type', 'application'))
);
}
foreach ($service->databases as $db) {
$persistentStorages = $persistentStorages->merge(
$db->persistentStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $db->uuid)->setAttribute('resource_type', 'database'))
);
$fileStorages = $fileStorages->merge(
$db->fileStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $db->uuid)->setAttribute('resource_type', 'database'))
);
}
return response()->json([
'persistent_storages' => $persistentStorages->sortBy('id')->values(),
'file_storages' => $fileStorages->sortBy('id')->values(),
]);
}
#[OA\Post(
summary: 'Create Storage',
description: 'Create a persistent storage or file storage for a service sub-resource.',
path: '/services/{uuid}/storages',
operationId: 'create-storage-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
requestBody: new OA\RequestBody(
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['type', 'mount_path', 'resource_uuid'],
properties: [
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'],
'resource_uuid' => ['type' => 'string', 'description' => 'UUID of the service application or database sub-resource.'],
'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
],
additionalProperties: false,
),
),
],
),
responses: [
new OA\Response(
response: 201,
description: 'Storage created.',
content: new OA\JsonContent(type: 'object'),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function create_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('update', $service);
$validator = customApiValidator($request->all(), [
'type' => 'required|string|in:persistent,file',
'resource_uuid' => 'required|string',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$subResource = $service->applications()->where('uuid', $request->resource_uuid)->first();
if (! $subResource) {
$subResource = $service->databases()->where('uuid', $request->resource_uuid)->first();
}
if (! $subResource) {
return response()->json(['message' => 'Service resource not found.'], 404);
}
if ($request->type === 'persistent') {
if (! $request->name) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['name' => 'The name field is required for persistent storages.'],
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'persistent'."]),
], 422);
}
$storage = LocalPersistentVolume::create([
'name' => $subResource->uuid.'-'.$request->name,
'mount_path' => $request->mount_path,
'host_path' => $request->host_path,
'resource_id' => $subResource->id,
'resource_type' => $subResource->getMorphClass(),
]);
return response()->json($storage, 201);
}
// File storage
$typeSpecificInvalidFields = array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'file'."]),
], 422);
}
$isDirectory = $request->boolean('is_directory', false);
if ($isDirectory) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'],
], 422);
}
$fsPath = str($request->fs_path)->trim()->start('/')->value();
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($fsPath, 'storage source path');
validateShellSafePath($mountPath, 'storage destination path');
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'is_directory' => true,
'resource_id' => $subResource->id,
'resource_type' => get_class($subResource),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($mountPath, 'file storage path');
$fsPath = service_configuration_dir().'/'.$service->uuid.$mountPath;
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => $request->content,
'is_directory' => false,
'resource_id' => $subResource->id,
'resource_type' => get_class($subResource),
]);
}
return response()->json($storage, 201);
}
#[OA\Patch(
summary: 'Update Storage',
description: 'Update a persistent storage or file storage by service UUID.',
path: '/services/{uuid}/storages',
operationId: 'update-storage-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['type'],
properties: [
'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'],
'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'],
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'],
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'],
],
additionalProperties: false,
),
),
],
),
responses: [
new OA\Response(
response: 200,
description: 'Storage updated.',
content: new OA\JsonContent(type: 'object'),
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json([
'message' => 'Service not found.',
], 404);
}
$this->authorize('update', $service);
$validator = customApiValidator($request->all(), [
'uuid' => 'string',
'id' => 'integer',
'type' => 'required|string|in:persistent,file',
'is_preview_suffix_enabled' => 'boolean',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
]);
$allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$storageUuid = $request->input('uuid');
$storageId = $request->input('id');
if (! $storageUuid && ! $storageId) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['uuid' => 'Either uuid or id is required.'],
], 422);
}
$lookupField = $storageUuid ? 'uuid' : 'id';
$lookupValue = $storageUuid ?? $storageId;
$storage = null;
if ($request->type === 'persistent') {
foreach ($service->applications as $app) {
$storage = $app->persistentStorages->where($lookupField, $lookupValue)->first();
if ($storage) {
break;
}
}
if (! $storage) {
foreach ($service->databases as $db) {
$storage = $db->persistentStorages->where($lookupField, $lookupValue)->first();
if ($storage) {
break;
}
}
}
} else {
foreach ($service->applications as $app) {
$storage = $app->fileStorages->where($lookupField, $lookupValue)->first();
if ($storage) {
break;
}
}
if (! $storage) {
foreach ($service->databases as $db) {
$storage = $db->fileStorages->where($lookupField, $lookupValue)->first();
if ($storage) {
break;
}
}
}
}
if (! $storage) {
return response()->json([
'message' => 'Storage not found.',
], 404);
}
$isReadOnly = $storage->shouldBeReadOnlyInUI();
$editableOnlyFields = ['name', 'mount_path', 'host_path', 'content'];
$requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all()));
if ($isReadOnly && ! empty($requestedEditableFields)) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.',
'read_only_fields' => array_values($requestedEditableFields),
], 422);
}
// Reject fields that don't apply to the given storage type
if (! $isReadOnly) {
$typeSpecificInvalidFields = $request->type === 'persistent'
? array_intersect(['content'], array_keys($request->all()))
: array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]),
], 422);
}
}
// Always allowed
if ($request->has('is_preview_suffix_enabled')) {
$storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
}
// Only for editable storages
if (! $isReadOnly) {
if ($request->type === 'persistent') {
if ($request->has('name')) {
$storage->name = $request->name;
}
if ($request->has('mount_path')) {
$storage->mount_path = $request->mount_path;
}
if ($request->has('host_path')) {
$storage->host_path = $request->host_path;
}
} else {
if ($request->has('mount_path')) {
$storage->mount_path = $request->mount_path;
}
if ($request->has('content')) {
$storage->content = $request->content;
}
}
}
$storage->save();
return response()->json($storage);
}
#[OA\Delete(
summary: 'Delete Storage',
description: 'Delete a persistent storage or file storage by service UUID.',
path: '/services/{uuid}/storages/{storage_uuid}',
operationId: 'delete-storage-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'storage_uuid',
in: 'path',
description: 'UUID of the storage.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent(
properties: [new OA\Property(property: 'message', type: 'string')],
)),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function delete_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('update', $service);
$storageUuid = $request->route('storage_uuid');
$storage = null;
foreach ($service->applications as $app) {
$storage = $app->persistentStorages->where('uuid', $storageUuid)->first();
if ($storage) {
break;
}
}
if (! $storage) {
foreach ($service->databases as $db) {
$storage = $db->persistentStorages->where('uuid', $storageUuid)->first();
if ($storage) {
break;
}
}
}
if (! $storage) {
foreach ($service->applications as $app) {
$storage = $app->fileStorages->where('uuid', $storageUuid)->first();
if ($storage) {
break;
}
}
}
if (! $storage) {
foreach ($service->databases as $db) {
$storage = $db->fileStorages->where('uuid', $storageUuid)->first();
if ($storage) {
break;
}
}
}
if (! $storage) {
return response()->json(['message' => 'Storage not found.'], 404);
}
if ($storage->shouldBeReadOnlyInUI()) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.',
], 422);
}
if ($storage instanceof LocalFileVolume) {
$storage->deleteStorageOnServer();
}
$storage->delete();
return response()->json(['message' => 'Storage deleted.']);
}
}
@@ -14,14 +14,6 @@ class TeamController extends Controller
'custom_server_limit',
'pivot',
]);
if (request()->attributes->get('can_read_sensitive', false) === false) {
$team->makeHidden([
'smtp_username',
'smtp_password',
'resend_api_key',
'telegram_token',
]);
}
return serializeApiResponse($team);
}
+31 -31
View File
@@ -108,9 +108,31 @@ class Controller extends BaseController
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
public function showInvitation()
{
$invitationUuid = request()->route('uuid');
$invitation = TeamInvitation::whereUuid($invitationUuid)->firstOrFail();
$user = User::whereEmail($invitation->email)->firstOrFail();
if (Auth::id() !== $user->id) {
abort(400, 'You are not allowed to accept this invitation.');
}
if (! $invitation->isValid()) {
abort(400, 'Invitation expired.');
}
$alreadyMember = $user->teams()->where('team_id', $invitation->team->id)->exists();
return view('invitation.accept', [
'invitation' => $invitation,
'team' => $invitation->team,
'alreadyMember' => $alreadyMember,
]);
}
public function acceptInvitation()
{
$resetPassword = request()->query('reset-password');
$invitationUuid = request()->route('uuid');
$invitation = TeamInvitation::whereUuid($invitationUuid)->firstOrFail();
@@ -119,43 +141,21 @@ class Controller extends BaseController
if (Auth::id() !== $user->id) {
abort(400, 'You are not allowed to accept this invitation.');
}
$invitationValid = $invitation->isValid();
if ($invitationValid) {
if ($resetPassword) {
$user->update([
'password' => Hash::make($invitationUuid),
'force_password_reset' => true,
]);
}
if ($user->teams()->where('team_id', $invitation->team->id)->exists()) {
$invitation->delete();
return redirect()->route('team.index');
}
$user->teams()->attach($invitation->team->id, ['role' => $invitation->role]);
$invitation->delete();
refreshSession($invitation->team);
return redirect()->route('team.index');
} else {
if (! $invitation->isValid()) {
abort(400, 'Invitation expired.');
}
}
public function revokeInvitation()
{
$invitation = TeamInvitation::whereUuid(request()->route('uuid'))->firstOrFail();
$user = User::whereEmail($invitation->email)->firstOrFail();
if (is_null(Auth::user())) {
return redirect()->route('login');
}
if (Auth::id() !== $user->id) {
abort(401);
if ($user->teams()->where('team_id', $invitation->team->id)->exists()) {
$invitation->delete();
return redirect()->route('team.index');
}
$user->teams()->attach($invitation->team->id, ['role' => $invitation->role]);
$invitation->delete();
refreshSession($invitation->team);
return redirect()->route('team.index');
}
}
+6
View File
@@ -55,6 +55,9 @@ class Github extends Controller
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
}
if (! $branch) {
return response('Nothing to do. No branch found in the request.');
}
@@ -246,6 +249,9 @@ class Github extends Controller
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
}
if (! $id || ! $branch) {
return response('Nothing to do. No id or branch found.');
}
@@ -25,7 +25,7 @@ class CheckForcePasswordReset
}
$force_password_reset = auth()->user()->force_password_reset;
if ($force_password_reset) {
if ($request->routeIs('auth.force-password-reset') || $request->path() === 'force-password-reset' || $request->path() === 'livewire/update' || $request->path() === 'logout') {
if ($request->routeIs('auth.force-password-reset') || $request->path() === 'force-password-reset' || $request->path() === 'two-factor-challenge' || $request->path() === 'livewire/update' || $request->path() === 'logout') {
return $next($request);
}
+7
View File
@@ -91,6 +91,13 @@ class TrustHosts extends Middleware
// Trust all subdomains of APP_URL as fallback
$trustedHosts[] = $this->allSubdomainsOfApplicationUrl();
// Always trust loopback addresses so local access works even when FQDN is configured
foreach (['localhost', '127.0.0.1', '[::1]'] as $localHost) {
if (! in_array($localHost, $trustedHosts, true)) {
$trustedHosts[] = $localHost;
}
}
return array_filter($trustedHosts);
}
}
+22
View File
@@ -25,4 +25,26 @@ class TrustProxies extends Middleware
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
/**
* Handle the request.
*
* Wraps $next so that after proxy headers are resolved (X-Forwarded-Proto processed),
* the Secure cookie flag is auto-enabled when the request is over HTTPS.
* This ensures session cookies are correctly marked Secure when behind an HTTPS
* reverse proxy (Cloudflare Tunnel, nginx, etc.) even when SESSION_SECURE_COOKIE
* is not explicitly set in .env.
*/
public function handle($request, \Closure $next)
{
return parent::handle($request, function ($request) use ($next) {
// At this point proxy headers have been applied to the request,
// so $request->secure() correctly reflects the actual protocol.
if ($request->secure() && config('session.secure') === null) {
config(['session.secure' => true]);
}
return $next($request);
});
}
}
+289 -138
View File
@@ -19,6 +19,7 @@ use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Notifications\Application\DeploymentFailed;
use App\Notifications\Application\DeploymentSuccess;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\ExecuteRemoteCommand;
use Carbon\Carbon;
@@ -171,6 +172,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private bool $dockerBuildkitSupported = false;
private bool $dockerSecretsSupported = false;
private bool $skip_build = false;
private Collection|string $build_secrets;
@@ -221,7 +224,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->preserveRepository = $this->application->settings->is_preserve_repository_enabled;
$this->basedir = $this->application->generateBaseDir($this->deployment_uuid);
$this->workdir = "{$this->basedir}".rtrim($this->application->base_directory, '/');
$baseDir = $this->application->base_directory;
if ($baseDir && $baseDir !== '/') {
$this->validatePathField($baseDir, 'base_directory');
}
$this->workdir = "{$this->basedir}".rtrim($baseDir, '/');
$this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}";
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
@@ -251,7 +258,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
if ($this->application->build_pack === 'dockerfile') {
if (data_get($this->application, 'dockerfile_location')) {
$this->dockerfile_location = $this->application->dockerfile_location;
$this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location');
}
}
}
@@ -281,7 +288,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Make sure the private key is stored in the filesystem
$this->server->privateKey->storeInFileSystem();
// Generate custom host<->ip mapping
$allContainers = instant_remote_process(["docker network inspect {$this->destination->network} -f '{{json .Containers}}' "], $this->server);
$safeNetwork = escapeshellarg($this->destination->network);
$allContainers = instant_remote_process(["docker network inspect {$safeNetwork} -f '{{json .Containers}}' "], $this->server);
if (! is_null($allContainers)) {
$allContainers = format_docker_command_output_to_json($allContainers);
@@ -310,7 +318,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
if ($this->application->dockerfile_target_build) {
$this->buildTarget = " --target {$this->application->dockerfile_target_build} ";
$target = $this->application->dockerfile_target_build;
if (! preg_match(ValidationPatterns::DOCKER_TARGET_PATTERN, $target)) {
throw new \RuntimeException('Invalid dockerfile_target_build: contains forbidden characters.');
}
$this->buildTarget = " --target {$target} ";
}
// Check custom port
@@ -381,13 +393,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function detectBuildKitCapabilities(): void
{
// If build secrets are not enabled, skip detection and use traditional args
if (! $this->application->settings->use_build_secrets) {
$this->dockerBuildkitSupported = false;
return;
}
$serverToCheck = $this->use_build_server ? $this->build_server : $this->server;
$serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})";
@@ -403,53 +408,55 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) {
$this->dockerBuildkitSupported = false;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+). Build secrets feature disabled.");
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+).");
return;
}
$buildkitEnabled = instant_remote_process(
// Check buildx availability (always installed by Coolify on Docker 24.0+)
$buildxAvailable = instant_remote_process(
["docker buildx version >/dev/null 2>&1 && echo 'available' || echo 'not-available'"],
$serverToCheck
);
if (trim($buildkitEnabled) !== 'available') {
if (trim($buildxAvailable) === 'available') {
$this->dockerBuildkitSupported = true;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
} else {
// Fallback: test DOCKER_BUILDKIT=1 support via --progress flag
$buildkitTest = instant_remote_process(
["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && echo 'supported' || echo 'not-supported'"],
$serverToCheck
);
if (trim($buildkitTest) === 'supported') {
$this->dockerBuildkitSupported = true;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit secrets support detected on {$serverName}.");
$this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit support detected on {$serverName}.");
} else {
$this->dockerBuildkitSupported = false;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not have BuildKit secrets support.");
$this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.');
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit. Build output progress will be limited.");
}
} else {
// Buildx is available, which means BuildKit is available
// Now specifically test for secrets support
}
// If build secrets are enabled and BuildKit is available, verify --secret flag support
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) {
$secretsTest = instant_remote_process(
["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
$serverToCheck
);
if (trim($secretsTest) === 'supported') {
$this->dockerBuildkitSupported = true;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
$this->dockerSecretsSupported = true;
$this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
} else {
$this->dockerBuildkitSupported = false;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with Buildx on {$serverName}, but secrets not supported.");
$this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.');
$this->dockerSecretsSupported = false;
$this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments.");
}
}
} catch (\Exception $e) {
} catch (Exception $e) {
$this->dockerBuildkitSupported = false;
$this->dockerSecretsSupported = false;
$this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
$this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but detection failed. Using traditional build arguments.');
}
}
@@ -486,7 +493,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Then handle side effects - these should not fail the deployment
try {
GetContainersStatus::dispatch($this->server);
} catch (\Exception $e) {
} catch (Exception $e) {
\Log::warning('Failed to dispatch GetContainersStatus for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
@@ -494,7 +501,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->application->is_github_based()) {
try {
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED);
} catch (\Exception $e) {
} catch (Exception $e) {
\Log::warning('Failed to dispatch PR update for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
}
@@ -502,13 +509,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
try {
$this->run_post_deployment_command();
} catch (\Exception $e) {
} catch (Exception $e) {
\Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage());
}
try {
$this->application->isConfigurationChanged(true);
} catch (\Exception $e) {
} catch (Exception $e) {
\Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
}
@@ -571,15 +578,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function deploy_docker_compose_buildpack()
{
if (data_get($this->application, 'docker_compose_location')) {
$this->docker_compose_location = $this->application->docker_compose_location;
$this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location');
}
if (data_get($this->application, 'docker_compose_custom_start_command')) {
$this->validateShellSafeCommand($this->application->docker_compose_custom_start_command, 'docker_compose_custom_start_command');
$this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command;
if (! str($this->docker_compose_custom_start_command)->contains('--project-directory')) {
$this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value();
$projectDir = $this->preserveRepository ? $this->application->workdir() : $this->workdir;
$this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value();
}
}
if (data_get($this->application, 'docker_compose_custom_build_command')) {
$this->validateShellSafeCommand($this->application->docker_compose_custom_build_command, 'docker_compose_custom_build_command');
$this->docker_compose_custom_build_command = $this->application->docker_compose_custom_build_command;
if (! str($this->docker_compose_custom_build_command)->contains('--project-directory')) {
$this->docker_compose_custom_build_command = str($this->docker_compose_custom_build_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value();
@@ -632,7 +642,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// For raw compose, we cannot automatically add secrets configuration
// User must define it manually in their docker-compose file
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
$this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.');
}
} else {
@@ -653,7 +663,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
// Add build secrets to compose file if enabled and BuildKit is supported
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
$composeFile = $this->add_build_secrets_to_compose($composeFile);
}
@@ -687,10 +697,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
// Inject build arguments after build subcommand if not using build secrets
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof Collection && $this->build_args->isNotEmpty()) {
$build_args_string = $this->build_args->implode(' ');
// Escape single quotes for bash -c context used by executeInDocker
$build_args_string = str_replace("'", "'\\''", $build_args_string);
// Inject build args right after 'build' subcommand (not at the end)
$original_command = $build_command;
@@ -702,9 +710,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
);
try {
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
);
} catch (\RuntimeException $e) {
if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) {
throw new DeploymentException("Custom build command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_build_command}");
}
throw $e;
}
} else {
$command = "{$this->coolify_variables} docker compose";
// Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
@@ -719,10 +735,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull";
}
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof Collection && $this->build_args->isNotEmpty()) {
$build_args_string = $this->build_args->implode(' ');
// Escape single quotes for bash -c context used by executeInDocker
$build_args_string = str_replace("'", "'\\''", $build_args_string);
$command .= " {$build_args_string}";
$this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.');
}
@@ -768,9 +782,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
);
$this->write_deployment_configurations();
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true],
);
try {
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
);
} catch (\RuntimeException $e) {
if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) {
throw new DeploymentException("Custom start command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_start_command}");
}
throw $e;
}
} else {
$this->write_deployment_configurations();
$this->docker_compose_location = '/docker-compose.yaml';
@@ -780,7 +803,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$command .= " --env-file {$server_workdir}/.env";
$command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
$this->execute_remote_command(
['command' => $command, 'hidden' => true],
['command' => $command, 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
);
}
} else {
@@ -795,9 +818,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
);
$this->write_deployment_configurations();
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => true],
);
if ($this->preserveRepository) {
$this->execute_remote_command(
['command' => "cd {$server_workdir} && {$start_command}", 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
);
} else {
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
);
}
} else {
$command = "{$this->coolify_variables} docker compose";
if ($this->preserveRepository) {
@@ -807,14 +836,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->write_deployment_configurations();
$this->execute_remote_command(
['command' => $command, 'hidden' => true],
['command' => $command, 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
);
} else {
// Always use .env file
$command .= " --env-file {$this->workdir}/.env";
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d";
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, $command), 'hidden' => true],
[executeInDocker($this->deployment_uuid, $command), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
);
$this->write_deployment_configurations();
}
@@ -831,7 +860,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->server = $this->build_server;
}
if (data_get($this->application, 'dockerfile_location')) {
$this->dockerfile_location = $this->application->dockerfile_location;
$this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location');
}
$this->prepare_builder_image();
$this->check_git_if_build_needed();
@@ -1086,10 +1115,21 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function just_restart()
{
$this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}.");
// Restart doesn't need the build server — disable it so the helper container
// is created on the deployment server with the correct network/flags.
$originalUseBuildServer = $this->use_build_server;
$this->use_build_server = false;
$this->prepare_builder_image();
$this->check_git_if_build_needed();
$this->generate_image_names();
$this->check_image_locally_or_remotely();
// Restore before should_skip_build() — it may re-enter decide_what_to_do()
// for a full rebuild which needs the build server.
$this->use_build_server = $originalUseBuildServer;
$this->should_skip_build();
$this->completeDeployment();
}
@@ -1295,6 +1335,22 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
foreach ($runtime_environment_variables_preview as $env) {
$envs->push($env->key.'='.$env->real_value);
}
// Fall back to production env vars for keys not overridden by preview vars,
// but only when preview vars are configured. This ensures variables like
// DB_PASSWORD that are only set for production will be available in the
// preview .env file (fixing ${VAR} interpolation in docker-compose YAML),
// while avoiding leaking production values when previews aren't configured.
if ($runtime_environment_variables_preview->isNotEmpty()) {
$previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray();
$fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) {
return $env->is_runtime && ! in_array($env->key, $previewKeys);
});
foreach ($fallback_production_vars as $env) {
$envs->push($env->key.'='.$env->real_value);
}
}
// Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') {
if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) {
@@ -1800,7 +1856,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$counter = 1;
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) {
$this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}");
$healthcheckLabel = $this->application->health_check_type === 'cmd' ? 'Healthcheck command' : 'Healthcheck URL';
$this->application_deployment_queue->addLogEntry("{$healthcheckLabel} (inside the container): {$this->full_healthcheck_url}");
}
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
$sleeptime = 0;
@@ -1959,9 +2016,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} else {
if ($this->dockerConfigFileExists === 'OK') {
$runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
$safeNetwork = escapeshellarg($this->destination->network);
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} else {
$runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
$safeNetwork = escapeshellarg($this->destination->network);
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
}
}
if ($firstTry) {
@@ -2073,7 +2132,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function check_git_if_build_needed()
{
if (is_object($this->source) && $this->source->getMorphClass() === \App\Models\GithubApp::class && $this->source->is_public === false) {
if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) {
$repository = githubApi($this->source, "repos/{$this->customRepository}");
$data = data_get($repository, 'data');
$repository_project_id = data_get($data, 'id');
@@ -2116,7 +2175,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
],
[
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"),
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"),
'hidden' => true,
'save' => 'git_commit_sha',
]
@@ -2179,7 +2238,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->create_workdir();
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 {$this->commit} --pretty=%B"),
executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 ".escapeshellarg($this->commit).' --pretty=%B'),
'hidden' => true,
'save' => 'commit_message',
]
@@ -2295,13 +2354,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->generate_nixpacks_env_variables();
$nixpacks_command = "nixpacks plan -f json {$this->env_nixpacks_args}";
if ($this->application->build_command) {
$nixpacks_command .= " --build-cmd \"{$this->application->build_command}\"";
$nixpacks_command .= ' --build-cmd '.escapeShellValue($this->application->build_command);
}
if ($this->application->start_command) {
$nixpacks_command .= " --start-cmd \"{$this->application->start_command}\"";
$nixpacks_command .= ' --start-cmd '.escapeShellValue($this->application->start_command);
}
if ($this->application->install_command) {
$nixpacks_command .= " --install-cmd \"{$this->application->install_command}\"";
$nixpacks_command .= ' --install-cmd '.escapeShellValue($this->application->install_command);
}
$nixpacks_command .= " {$this->workdir}";
@@ -2314,13 +2373,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->pull_request_id === 0) {
foreach ($this->application->nixpacks_environment_variables as $env) {
if (! is_null($env->real_value) && $env->real_value !== '') {
$this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
$value = ($env->is_literal || $env->is_multiline) ? trim($env->real_value, "'") : $env->real_value;
$this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}"));
}
}
} else {
foreach ($this->application->nixpacks_environment_variables_preview as $env) {
if (! is_null($env->real_value) && $env->real_value !== '') {
$this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
$value = ($env->is_literal || $env->is_multiline) ? trim($env->real_value, "'") : $env->real_value;
$this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}"));
}
}
}
@@ -2330,7 +2391,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$coolify_envs->each(function ($value, $key) {
// Only add environment variables with non-null and non-empty values
if (! is_null($value) && $value !== '') {
$this->env_nixpacks_args->push("--env {$key}={$value}");
$this->env_nixpacks_args->push('--env '.escapeShellValue("{$key}={$value}"));
}
});
@@ -2445,7 +2506,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
$coolify_envs->each(function ($value, $key) {
$this->env_args->put($key, $value);
if (! is_null($value) && $value !== '') {
$this->env_args->put($key, $value);
}
});
// For build process, include only environment variables where is_buildtime = true
@@ -2725,7 +2788,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} else {
$volume_name = $persistentStorage->name;
}
if ($this->pull_request_id !== 0) {
$isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true);
if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) {
$volume_name = addPreviewDeploymentSuffix($volume_name, $this->pull_request_id);
}
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
@@ -2743,7 +2807,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
$name = $persistentStorage->name;
if ($this->pull_request_id !== 0) {
$isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true);
if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) {
$name = addPreviewDeploymentSuffix($name, $this->pull_request_id);
}
@@ -2758,29 +2823,62 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function generate_healthcheck_commands()
{
// Handle CMD type healthcheck
if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
$command = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->health_check_command);
// Defense in depth: validate command at runtime (matches input validation regex)
if (! preg_match('/^[a-zA-Z0-9 \-_.\/:=@,+]+$/', $command) || strlen($command) > 1000) {
$this->application_deployment_queue->addLogEntry('Warning: Health check command contains invalid characters or exceeds max length. Falling back to HTTP healthcheck.');
} else {
$this->full_healthcheck_url = $command;
return $command;
}
}
// HTTP type healthcheck (default)
if (! $this->application->health_check_port) {
$health_check_port = $this->application->ports_exposes_array[0];
$health_check_port = (int) $this->application->ports_exposes_array[0];
} else {
$health_check_port = $this->application->health_check_port;
$health_check_port = (int) $this->application->health_check_port;
}
if ($this->application->settings->is_static || $this->application->build_pack === 'static') {
$health_check_port = 80;
}
if ($this->application->health_check_path) {
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path}";
$generated_healthchecks_commands = [
"curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || exit 1",
];
$method = $this->sanitizeHealthCheckValue($this->application->health_check_method, '/^[A-Z]+$/', 'GET');
$scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http');
$host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost');
$path = $this->application->health_check_path
? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%]+$#', '/')
: null;
$url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/'));
$escapedMethod = escapeshellarg($method);
if ($path) {
$this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}{$path}";
} else {
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/";
$generated_healthchecks_commands = [
"curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || exit 1",
];
$this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}/";
}
$generated_healthchecks_commands = [
"curl -s -X {$escapedMethod} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1",
];
return implode(' ', $generated_healthchecks_commands);
}
private function sanitizeHealthCheckValue(string $value, string $pattern, string $default): string
{
if (preg_match($pattern, $value)) {
return $value;
}
return $default;
}
private function pull_latest_image($image)
{
$this->application_deployment_queue->addLogEntry("Pulling latest image ($image) from the registry.");
@@ -2817,7 +2915,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$nginx_config = base64_encode(defaultNginxConfiguration());
}
}
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}";
if ($this->dockerBuildkitSupported) {
$build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}";
} else {
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}";
}
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
@@ -2857,21 +2959,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function build_image()
{
// Add Coolify related variables to the build args/secrets
if ($this->dockerBuildkitSupported) {
// Coolify variables are already included in the secrets from generate_build_env_variables
// build_secrets is already a string at this point
} else {
if (! $this->dockerSecretsSupported) {
// Traditional build args approach - generate COOLIFY_ variables locally
// Generate COOLIFY_ variables locally for build args
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
$coolify_envs->each(function ($value, $key) {
$this->build_args->push("--build-arg '{$key}'");
});
$this->build_args = $this->build_args instanceof \Illuminate\Support\Collection
? $this->build_args->implode(' ')
: (string) $this->build_args;
}
// Always convert build_args Collection to string for command interpolation
$this->build_args = $this->build_args instanceof Collection
? $this->build_args->implode(' ')
: (string) $this->build_args;
$this->application_deployment_queue->addLogEntry('----------------------------------------');
if ($this->disableBuildCache) {
$this->application_deployment_queue->addLogEntry('Docker build cache is disabled. It will not be used during the build process.');
@@ -2899,7 +2999,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
@@ -2907,9 +3007,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
ray($build_command);
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
}
} else {
$this->execute_remote_command([
@@ -2919,18 +3018,16 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
}
}
@@ -2952,30 +3049,29 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
$safeNetwork = escapeshellarg($this->destination->network);
if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
}
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@@ -3010,7 +3106,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$nginx_config = base64_encode(defaultNginxConfiguration());
}
}
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
if ($this->dockerBuildkitSupported) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
}
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
@@ -3035,7 +3135,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
} else {
// Pure Dockerfile based deployment
if ($this->application->dockerfile) {
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
@@ -3044,12 +3144,19 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
} else {
$build_command = "DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
}
} else {
// Traditional build with args
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args (no --progress for legacy builder compatibility)
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@@ -3079,18 +3186,16 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
} else {
$this->execute_remote_command([
@@ -3100,18 +3205,16 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@@ -3132,7 +3235,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
// Use BuildKit with secrets
@@ -3144,19 +3247,17 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@@ -3332,7 +3433,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->analyzeBuildTimeVariables($variables);
}
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
$this->generate_build_secrets($variables);
$this->build_args = '';
} else {
@@ -3470,8 +3571,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
private function add_build_env_variables_to_dockerfile()
{
if ($this->dockerBuildkitSupported) {
// We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets
if ($this->dockerSecretsSupported) {
// We dont need to add ARG declarations when using Docker build secrets, as variables are passed with --secret flag
return;
}
@@ -3819,7 +3920,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->application_deployment_queue->addLogEntry("Service {$serviceName}: All required ARG declarations already exist.");
}
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
$fullDockerfilePath = "{$this->workdir}/{$dockerfilePath}";
$this->modify_dockerfile_for_secrets($fullDockerfilePath);
$this->application_deployment_queue->addLogEntry("Modified Dockerfile for service {$serviceName} to use build secrets.");
@@ -3869,7 +3970,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$composeFile['services'] = $services;
$existingSecrets = data_get($composeFile, 'secrets', []);
if ($existingSecrets instanceof \Illuminate\Support\Collection) {
if ($existingSecrets instanceof Collection) {
$existingSecrets = $existingSecrets->toArray();
}
$composeFile['secrets'] = array_replace($existingSecrets, $secrets);
@@ -3879,6 +3980,36 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
return $composeFile;
}
private function validatePathField(string $value, string $fieldName): string
{
if (! preg_match(ValidationPatterns::FILE_PATH_PATTERN, $value)) {
throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters.");
}
if (str_contains($value, '..')) {
throw new \RuntimeException("Invalid {$fieldName}: path traversal detected.");
}
return $value;
}
private function validateShellSafeCommand(string $value, string $fieldName): string
{
if (! preg_match(ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN, $value)) {
throw new \RuntimeException("Invalid {$fieldName}: contains forbidden shell characters.");
}
return $value;
}
private function validateContainerName(string $value): string
{
if (! preg_match(ValidationPatterns::CONTAINER_NAME_PATTERN, $value)) {
throw new \RuntimeException('Invalid container name: contains forbidden characters.');
}
return $value;
}
private function run_pre_deployment_command()
{
if (empty($this->application->pre_deployment_command)) {
@@ -3892,8 +4023,21 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
foreach ($containers as $container) {
$containerName = data_get($container, 'Names');
if ($containerName) {
$this->validateContainerName($containerName);
}
if ($containers->count() == 1 || str_starts_with($containerName, $this->application->pre_deployment_command_container.'-'.$this->application->uuid)) {
$cmd = "sh -c '".str_replace("'", "'\''", $this->application->pre_deployment_command)."'";
// Security: pre_deployment_command is intentionally treated as arbitrary shell input.
// Users (team members with deployment access) need full shell flexibility to run commands
// like "php artisan migrate", "npm run build", etc. inside their own application containers.
// The trust boundary is at the application/team ownership level — only authenticated team
// members can set these commands, and execution is scoped to the application's own container.
// The single-quote escaping here prevents breaking out of the sh -c wrapper, but does not
// restrict the command itself. Container names are validated separately via validateContainerName().
// Newlines are normalized to spaces to prevent injection via SSH heredoc transport
// (matches the pattern used for health_check_command at line ~2824).
$preCommand = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->pre_deployment_command);
$cmd = "sh -c '".str_replace("'", "'\''", $preCommand)."'";
$exec = "docker exec {$containerName} {$cmd}";
$this->execute_remote_command(
[
@@ -3919,8 +4063,15 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
foreach ($containers as $container) {
$containerName = data_get($container, 'Names');
if ($containerName) {
$this->validateContainerName($containerName);
}
if ($containers->count() == 1 || str_starts_with($containerName, $this->application->post_deployment_command_container.'-'.$this->application->uuid)) {
$cmd = "sh -c '".str_replace("'", "'\''", $this->application->post_deployment_command)."'";
// Security: post_deployment_command is intentionally treated as arbitrary shell input.
// See the equivalent comment in run_pre_deployment_command() for the full security rationale.
// Newlines are normalized to spaces to prevent injection via SSH heredoc transport.
$postCommand = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->post_deployment_command);
$cmd = "sh -c '".str_replace("'", "'\''", $postCommand)."'";
$exec = "docker exec {$containerName} {$cmd}";
try {
$this->execute_remote_command(
+2 -1
View File
@@ -6,12 +6,13 @@ use App\Events\ProxyStatusChangedUI;
use App\Models\Server;
use App\Notifications\Server\TraefikVersionOutdated;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class CheckTraefikVersionForServerJob implements ShouldQueue
class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+2 -1
View File
@@ -5,12 +5,13 @@ namespace App\Jobs;
use App\Enums\ProxyTypes;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class CheckTraefikVersionJob implements ShouldQueue
class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+106 -49
View File
@@ -91,7 +91,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
return;
}
if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) {
if (data_get($this->backup, 'database_type') === ServiceDatabase::class) {
$this->database = data_get($this->backup, 'database');
$this->server = $this->database->service->server;
$this->s3 = $this->backup->s3;
@@ -111,9 +111,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$status = str(data_get($this->database, 'status'));
if (! $status->startsWith('running') && $this->database->id !== 0) {
Log::info('DatabaseBackupJob skipped: database not running', [
'backup_id' => $this->backup->id,
'database_id' => $this->database->id,
'status' => (string) $status,
]);
return;
}
if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) {
if (data_get($this->backup, 'database_type') === ServiceDatabase::class) {
$databaseType = $this->database->databaseType();
$serviceUuid = $this->database->service->uuid;
$serviceName = str($this->database->service->name)->slug();
@@ -235,7 +241,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
}
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
// Continue without env vars - will be handled in backup_standalone_mongodb method
}
}
@@ -382,7 +388,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
} else {
throw new \Exception('Local backup file is empty or was not created');
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
// Local backup failed
if ($this->backup_log) {
$this->backup_log->update([
@@ -393,7 +399,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
's3_uploaded' => null,
]);
}
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
try {
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
} catch (Throwable $notifyException) {
Log::channel('scheduled-errors')->warning('Failed to send backup failure notification', [
'backup_id' => $this->backup->uuid,
'database' => $database,
'error' => $notifyException->getMessage(),
]);
}
continue;
}
@@ -409,7 +423,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
deleteBackupsLocally($this->backup_location, $this->server);
$localStorageDeleted = true;
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
// S3 upload failed but local backup succeeded
$s3UploadError = $e->getMessage();
}
@@ -433,18 +447,27 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
'local_storage_deleted' => $localStorageDeleted,
]);
// Send appropriate notification
if ($s3UploadError) {
$this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError));
} else {
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
// Send appropriate notification (wrapped in try-catch so notification
// failures never affect backup status — see GitHub issue #9088)
try {
if ($s3UploadError) {
$this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError));
} else {
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
}
} catch (Throwable $e) {
Log::channel('scheduled-errors')->warning('Failed to send backup success notification', [
'backup_id' => $this->backup->uuid,
'database' => $database,
'error' => $e->getMessage(),
]);
}
}
}
if ($this->backup_log && $this->backup_log->status === 'success') {
removeOldBackups($this->backup);
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
throw $e;
} finally {
if ($this->team) {
@@ -466,19 +489,23 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
// For service-based MongoDB, try to build URL from environment variables
if (filled($this->mongo_root_username) && filled($this->mongo_root_password)) {
// Use container name instead of server IP for service-based MongoDB
$url = "mongodb://{$this->mongo_root_username}:{$this->mongo_root_password}@{$this->container_name}:27017";
// URL-encode credentials to prevent URI injection
$encodedUser = rawurlencode($this->mongo_root_username);
$encodedPass = rawurlencode($this->mongo_root_password);
$url = "mongodb://{$encodedUser}:{$encodedPass}@{$this->container_name}:27017";
} else {
// If no environment variables are available, throw an exception
throw new \Exception('MongoDB credentials not found. Ensure MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD environment variables are available in the container.');
}
}
\Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]);
Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]);
$escapedUrl = escapeshellarg($url);
if ($databaseWithCollections === 'all') {
$commands[] = 'mkdir -p '.$this->backup_dir;
if (str($this->database->image)->startsWith('mongo:4')) {
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
$commands[] = "docker exec $this->container_name mongodump --uri=$escapedUrl --gzip --archive > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --gzip --archive > $this->backup_location";
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=$escapedUrl --gzip --archive > $this->backup_location";
}
} else {
if (str($databaseWithCollections)->contains(':')) {
@@ -496,15 +523,23 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
if ($collectionsToExclude->count() === 0) {
if (str($this->database->image)->startsWith('mongo:4')) {
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
$commands[] = "docker exec $this->container_name mongodump --uri=$escapedUrl --gzip --archive > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location";
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=$escapedUrl --db $escapedDatabaseName --gzip --archive > $this->backup_location";
}
} else {
// Validate and escape each collection name
$escapedCollections = $collectionsToExclude->map(function ($collection) {
$collection = trim($collection);
validateShellSafePath($collection, 'collection name');
return escapeshellarg($collection);
});
if (str($this->database->image)->startsWith('mongo:4')) {
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
$commands[] = "docker exec $this->container_name mongodump --uri=$escapedUrl --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=$escapedUrl --db $escapedDatabaseName --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > $this->backup_location";
}
}
}
@@ -513,7 +548,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
if ($this->backup_output === '') {
$this->backup_output = null;
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
$this->add_to_error_output($e->getMessage());
throw $e;
}
@@ -525,15 +560,16 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$commands[] = 'mkdir -p '.$this->backup_dir;
$backupCommand = 'docker exec';
if ($this->postgres_password) {
$backupCommand .= " -e PGPASSWORD=\"{$this->postgres_password}\"";
$backupCommand .= ' -e PGPASSWORD='.escapeshellarg($this->postgres_password);
}
$escapedUsername = escapeshellarg($this->database->postgres_user);
if ($this->backup->dump_all) {
$backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
$backupCommand .= " $this->container_name pg_dumpall --username $escapedUsername | gzip > $this->backup_location";
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
$backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $escapedDatabase > $this->backup_location";
$backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username $escapedUsername $escapedDatabase > $this->backup_location";
}
$commands[] = $backupCommand;
@@ -542,7 +578,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
if ($this->backup_output === '') {
$this->backup_output = null;
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
$this->add_to_error_output($e->getMessage());
throw $e;
}
@@ -552,20 +588,21 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
{
try {
$commands[] = 'mkdir -p '.$this->backup_dir;
$escapedPassword = escapeshellarg($this->database->mysql_root_password);
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
$commands[] = "docker exec $this->container_name mysqldump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location";
$commands[] = "docker exec $this->container_name mysqldump -u root -p$escapedPassword $escapedDatabase > $this->backup_location";
}
$this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
$this->backup_output = null;
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
$this->add_to_error_output($e->getMessage());
throw $e;
}
@@ -575,20 +612,21 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
{
try {
$commands[] = 'mkdir -p '.$this->backup_dir;
$escapedPassword = escapeshellarg($this->database->mariadb_root_password);
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location";
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p$escapedPassword $escapedDatabase > $this->backup_location";
}
$this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
$this->backup_output = null;
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
$this->add_to_error_output($e->getMessage());
throw $e;
}
@@ -619,21 +657,28 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
private function upload_to_s3(): void
{
if (is_null($this->s3)) {
$this->backup->update([
'save_s3' => false,
's3_storage_id' => null,
]);
throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.');
}
try {
if (is_null($this->s3)) {
return;
}
$key = $this->s3->key;
$secret = $this->s3->secret;
// $region = $this->s3->region;
$bucket = $this->s3->bucket;
$endpoint = $this->s3->endpoint;
$this->s3->testConnection(shouldSave: true);
if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) {
if (data_get($this->backup, 'database_type') === ServiceDatabase::class) {
$network = $this->database->service->destination->network;
} else {
$network = $this->database->destination->network;
}
$safeNetwork = escapeshellarg($network);
$fullImageName = $this->getFullImageName();
@@ -645,13 +690,13 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
if (isDev()) {
if ($this->database->name === 'coolify-db') {
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file;
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
$commands[] = "docker run -d --network {$safeNetwork} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
} else {
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name.$this->backup_file;
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
$commands[] = "docker run -d --network {$safeNetwork} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
}
} else {
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
$commands[] = "docker run -d --network {$safeNetwork} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
}
// Escape S3 credentials to prevent command injection
@@ -664,7 +709,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
$this->s3_uploaded = true;
} catch (\Throwable $e) {
} catch (Throwable $e) {
$this->s3_uploaded = false;
$this->add_to_error_output($e->getMessage());
throw $e;
@@ -698,20 +743,32 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first();
if ($log) {
$log->update([
'status' => 'failed',
'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'),
'size' => 0,
'filename' => null,
'finished_at' => Carbon::now(),
]);
// Don't overwrite a successful backup status — a post-backup error
// (e.g. notification failure) should not retroactively mark the backup
// as failed (see GitHub issue #9088)
if ($log->status !== 'success') {
$log->update([
'status' => 'failed',
'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'),
'size' => 0,
'filename' => null,
'finished_at' => Carbon::now(),
]);
}
}
// Notify team about permanent failure
if ($this->team) {
// Notify team about permanent failure (only if backup didn't already succeed)
if ($this->team && $log?->status !== 'success') {
$databaseName = $log?->database_name ?? 'unknown';
$output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error';
$this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName));
try {
$this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName));
} catch (Throwable $e) {
Log::channel('scheduled-errors')->warning('Failed to send backup permanent failure notification', [
'backup_id' => $this->backup->uuid,
'error' => $e->getMessage(),
]);
}
}
}
}
+15 -5
View File
@@ -39,19 +39,27 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
public bool $manualCleanup = false,
public bool $deleteUnusedVolumes = false,
public bool $deleteUnusedNetworks = false
) {}
) {
$this->onQueue('high');
}
public function handle(): void
{
try {
if (! $this->server->isFunctional()) {
return;
}
$this->execution_log = DockerCleanupExecution::create([
'server_id' => $this->server->id,
]);
if (! $this->server->isFunctional()) {
$this->execution_log->update([
'status' => 'failed',
'message' => 'Server is not functional (unreachable, unusable, or disabled)',
'finished_at' => Carbon::now()->toImmutable(),
]);
return;
}
$this->usageBefore = $this->server->getDiskUsage();
if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) {
@@ -91,6 +99,8 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
$this->server->team?->notify(new DockerCleanupSuccess($this->server, $message));
event(new DockerCleanupDone($this->execution_log));
return;
}
if ($this->usageBefore >= $this->server->settings->docker_cleanup_threshold) {
+45 -32
View File
@@ -24,6 +24,7 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Laravel\Horizon\Contracts\Silenced;
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
@@ -130,7 +131,14 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$this->containers = collect(data_get($data, 'containers'));
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
// Only dispatch storage check when disk percentage actually changes
$storageCacheKey = 'storage-check:'.$this->server->id;
$lastPercentage = Cache::get($storageCacheKey);
if ($lastPercentage === null || (string) $lastPercentage !== (string) $filesystemUsageRoot) {
Cache::put($storageCacheKey, $filesystemUsageRoot, 600);
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
}
if ($this->containers->isEmpty()) {
return;
@@ -207,6 +215,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$serviceId = $labels->get('coolify.serviceId');
$subType = $labels->get('coolify.service.subType');
$subId = $labels->get('coolify.service.subId');
if (empty(trim((string) $subId))) {
continue;
}
if ($subType === 'application') {
$this->foundServiceApplicationIds->push($subId);
// Store container status for aggregation
@@ -296,6 +307,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus;
$application->save();
} elseif ($aggregatedStatus) {
$application->update(['last_online_at' => now()]);
}
continue;
@@ -310,6 +323,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus;
$application->save();
} elseif ($aggregatedStatus) {
$application->update(['last_online_at' => now()]);
}
}
}
@@ -324,6 +339,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
// Parse key: serviceId:subType:subId
[$serviceId, $subType, $subId] = explode(':', $key);
if (empty($subId)) {
continue;
}
$service = $this->services->where('id', $serviceId)->first();
if (! $service) {
continue;
@@ -332,9 +351,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
$subResource = null;
if ($subType === 'application') {
$subResource = $service->applications()->where('id', $subId)->first();
$subResource = $service->applications->where('id', $subId)->first();
} elseif ($subType === 'database') {
$subResource = $service->databases()->where('id', $subId)->first();
$subResource = $service->databases->where('id', $subId)->first();
}
if (! $subResource) {
@@ -356,6 +375,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus;
$subResource->save();
} elseif ($aggregatedStatus) {
$subResource->update(['last_online_at' => now()]);
}
continue;
@@ -371,6 +392,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus;
$subResource->save();
} elseif ($aggregatedStatus) {
$subResource->update(['last_online_at' => now()]);
}
}
}
@@ -384,6 +407,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if ($application->status !== $containerStatus) {
$application->status = $containerStatus;
$application->save();
} else {
$application->update(['last_online_at' => now()]);
}
}
@@ -398,6 +423,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if ($application->status !== $containerStatus) {
$application->status = $containerStatus;
$application->save();
} else {
$application->update(['last_online_at' => now()]);
}
}
@@ -473,8 +500,13 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
} catch (\Throwable $e) {
}
} else {
// Connect proxy to networks asynchronously to avoid blocking the status update
ConnectProxyToNetworksJob::dispatch($this->server);
// Connect proxy to networks periodically (every 10 min) to avoid excessive job dispatches.
// On-demand triggers (new network, service deploy) use dispatchSync() and bypass this.
$proxyCacheKey = 'connect-proxy:'.$this->server->id;
if (! Cache::has($proxyCacheKey)) {
Cache::put($proxyCacheKey, true, 600);
ConnectProxyToNetworksJob::dispatch($this->server);
}
}
}
}
@@ -488,6 +520,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if ($database->status !== $containerStatus) {
$database->status = $containerStatus;
$database->save();
} else {
$database->update(['last_online_at' => now()]);
}
if ($this->isRunning($containerStatus) && $tcpProxy) {
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
@@ -525,8 +559,12 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$database = $this->databases->where('uuid', $databaseUuid)->first();
if ($database) {
if (! str($database->status)->startsWith('exited')) {
$database->status = 'exited';
$database->save();
$database->update([
'status' => 'exited',
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
}
if ($database->is_public) {
StopDatabaseProxy::dispatch($database);
@@ -535,31 +573,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
});
}
private function updateServiceSubStatus(string $serviceId, string $subType, string $subId, string $containerStatus)
{
$service = $this->services->where('id', $serviceId)->first();
if (! $service) {
return;
}
if ($subType === 'application') {
$application = $service->applications()->where('id', $subId)->first();
if ($application) {
if ($application->status !== $containerStatus) {
$application->status = $containerStatus;
$application->save();
}
}
} elseif ($subType === 'database') {
$database = $service->databases()->where('id', $subId)->first();
if ($database) {
if ($database->status !== $containerStatus) {
$database->status = $containerStatus;
$database->save();
}
}
}
}
private function updateNotFoundServiceStatus()
{
$notFoundServiceApplicationIds = $this->allServiceApplicationIds->diff($this->foundServiceApplicationIds);
+2 -1
View File
@@ -7,13 +7,14 @@ use App\Models\SslCertificate;
use App\Models\Team;
use App\Notifications\SslExpirationNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class RegenerateSslCertJob implements ShouldQueue
class RegenerateSslCertJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+165 -48
View File
@@ -6,7 +6,6 @@ use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Team;
use Cron\CronExpression;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -15,7 +14,9 @@ use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
class ScheduledJobManager implements ShouldQueue
{
@@ -27,6 +28,10 @@ class ScheduledJobManager implements ShouldQueue
*/
private ?Carbon $executionTime = null;
private int $dispatchedCount = 0;
private int $skippedCount = 0;
/**
* Create a new job instance.
*/
@@ -50,6 +55,11 @@ class ScheduledJobManager implements ShouldQueue
*/
public function middleware(): array
{
// Self-healing: clear any stale lock before WithoutOverlapping tries to acquire it.
// Stale locks (TTL = -1) can occur during upgrades, Redis restarts, or edge cases.
// @see https://github.com/coollabsio/coolify/issues/8327
self::clearStaleLockIfPresent();
return [
(new WithoutOverlapping('scheduled-job-manager'))
->expireAfter(90) // Lock expires after 90s to handle high-load environments with many tasks
@@ -57,10 +67,44 @@ class ScheduledJobManager implements ShouldQueue
];
}
/**
* Clear a stale WithoutOverlapping lock if it has no TTL (TTL = -1).
*
* This provides continuous self-healing since it runs every time the job is dispatched.
* Stale locks permanently block all scheduled job executions with no user-visible error.
*/
private static function clearStaleLockIfPresent(): void
{
try {
$cachePrefix = config('cache.prefix', '');
$lockKey = $cachePrefix.'laravel-queue-overlap:'.self::class.':scheduled-job-manager';
$ttl = Redis::connection('default')->ttl($lockKey);
if ($ttl === -1) {
Redis::connection('default')->del($lockKey);
Log::channel('scheduled')->warning('Cleared stale ScheduledJobManager lock', [
'lock_key' => $lockKey,
]);
}
} catch (\Throwable $e) {
// Never let lock cleanup failure prevent the job from running
Log::channel('scheduled-errors')->error('Failed to check/clear stale lock', [
'error' => $e->getMessage(),
]);
}
}
public function handle(): void
{
// Freeze the execution time at the start of the job
$this->executionTime = Carbon::now();
$this->dispatchedCount = 0;
$this->skippedCount = 0;
Log::channel('scheduled')->info('ScheduledJobManager started', [
'execution_time' => $this->executionTime->toIso8601String(),
]);
// Process backups - don't let failures stop task processing
try {
@@ -91,6 +135,20 @@ class ScheduledJobManager implements ShouldQueue
'trace' => $e->getTraceAsString(),
]);
}
Log::channel('scheduled')->info('ScheduledJobManager completed', [
'execution_time' => $this->executionTime->toIso8601String(),
'duration_ms' => $this->executionTime->diffInMilliseconds(Carbon::now()),
'dispatched' => $this->dispatchedCount,
'skipped' => $this->skippedCount,
]);
// Write heartbeat so the UI can detect when the scheduler has stopped
try {
Cache::put('scheduled-job-manager:heartbeat', now()->toIso8601String(), 300);
} catch (\Throwable) {
// Non-critical; don't let heartbeat failure affect the job
}
}
private function processScheduledBackups(): void
@@ -101,12 +159,20 @@ class ScheduledJobManager implements ShouldQueue
foreach ($backups as $backup) {
try {
// Apply the same filtering logic as the original
if (! $this->shouldProcessBackup($backup)) {
$server = $backup->server();
$skipReason = $this->getBackupSkipReason($backup, $server);
if ($skipReason !== null) {
$this->skippedCount++;
$this->logSkip('backup', $skipReason, [
'backup_id' => $backup->id,
'database_id' => $backup->database_id,
'database_type' => $backup->database_type,
'team_id' => $backup->team_id ?? null,
]);
continue;
}
$server = $backup->server();
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
if (validate_timezone($serverTimezone) === false) {
@@ -118,8 +184,16 @@ class ScheduledJobManager implements ShouldQueue
$frequency = VALID_CRON_STRINGS[$frequency];
}
if ($this->shouldRunNow($frequency, $serverTimezone)) {
if (shouldRunCronNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}", $this->executionTime)) {
DatabaseBackupJob::dispatch($backup);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Backup dispatched', [
'backup_id' => $backup->id,
'database_id' => $backup->database_id,
'database_type' => $backup->database_type,
'team_id' => $backup->team_id ?? null,
'server_id' => $server->id,
]);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing backup', [
@@ -138,11 +212,21 @@ class ScheduledJobManager implements ShouldQueue
foreach ($tasks as $task) {
try {
if (! $this->shouldProcessTask($task)) {
$server = $task->server();
// Phase 1: Critical checks (always — cheap, handles orphans and infra issues)
$criticalSkip = $this->getTaskCriticalSkipReason($task, $server);
if ($criticalSkip !== null) {
$this->skippedCount++;
$this->logSkip('task', $criticalSkip, [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server?->team_id,
]);
continue;
}
$server = $task->server();
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
if (validate_timezone($serverTimezone) === false) {
@@ -154,9 +238,31 @@ class ScheduledJobManager implements ShouldQueue
$frequency = VALID_CRON_STRINGS[$frequency];
}
if ($this->shouldRunNow($frequency, $serverTimezone)) {
ScheduledTaskJob::dispatch($task);
if (! shouldRunCronNow($frequency, $serverTimezone, "scheduled-task:{$task->id}", $this->executionTime)) {
continue;
}
// Phase 2: Runtime checks (only when cron is due — avoids noise for stopped resources)
$runtimeSkip = $this->getTaskRuntimeSkipReason($task);
if ($runtimeSkip !== null) {
$this->skippedCount++;
$this->logSkip('task', $runtimeSkip, [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server->team_id,
]);
continue;
}
ScheduledTaskJob::dispatch($task);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Task dispatched', [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server->team_id,
'server_id' => $server->id,
]);
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing task', [
'task_id' => $task->id,
@@ -166,79 +272,67 @@ class ScheduledJobManager implements ShouldQueue
}
}
private function shouldProcessBackup(ScheduledDatabaseBackup $backup): bool
private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string
{
if (blank(data_get($backup, 'database'))) {
$backup->delete();
return false;
return 'database_deleted';
}
$server = $backup->server();
if (blank($server)) {
$backup->delete();
return false;
return 'server_deleted';
}
if ($server->isFunctional() === false) {
return false;
return 'server_not_functional';
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
return false;
return 'subscription_unpaid';
}
return true;
return null;
}
private function shouldProcessTask(ScheduledTask $task): bool
private function getTaskCriticalSkipReason(ScheduledTask $task, ?Server $server): ?string
{
$service = $task->service;
$application = $task->application;
$server = $task->server();
if (blank($server)) {
$task->delete();
return false;
return 'server_deleted';
}
if ($server->isFunctional() === false) {
return false;
return 'server_not_functional';
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
return false;
return 'subscription_unpaid';
}
if (! $service && ! $application) {
if (! $task->service && ! $task->application) {
$task->delete();
return false;
return 'resource_deleted';
}
if ($application && str($application->status)->contains('running') === false) {
return false;
}
if ($service && str($service->status)->contains('running') === false) {
return false;
}
return true;
return null;
}
private function shouldRunNow(string $frequency, string $timezone): bool
private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string
{
$cron = new CronExpression($frequency);
if ($task->application && str($task->application->status)->contains('running') === false) {
return 'application_not_running';
}
// Use the frozen execution time, not the current time
// Fallback to current time if execution time is not set (shouldn't happen)
$baseTime = $this->executionTime ?? Carbon::now();
$executionTime = $baseTime->copy()->setTimezone($timezone);
if ($task->service && str($task->service->status)->contains('running') === false) {
return 'service_not_running';
}
return $cron->isDue($executionTime);
return null;
}
private function processDockerCleanups(): void
@@ -248,7 +342,15 @@ class ScheduledJobManager implements ShouldQueue
foreach ($servers as $server) {
try {
if (! $this->shouldProcessDockerCleanup($server)) {
$skipReason = $this->getDockerCleanupSkipReason($server);
if ($skipReason !== null) {
$this->skippedCount++;
$this->logSkip('docker_cleanup', $skipReason, [
'server_id' => $server->id,
'server_name' => $server->name,
'team_id' => $server->team_id,
]);
continue;
}
@@ -263,13 +365,19 @@ class ScheduledJobManager implements ShouldQueue
}
// Use the frozen execution time for consistent evaluation
if ($this->shouldRunNow($frequency, $serverTimezone)) {
if (shouldRunCronNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}", $this->executionTime)) {
DockerCleanupJob::dispatch(
$server,
false,
$server->settings->delete_unused_volumes,
$server->settings->delete_unused_networks
);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Docker cleanup dispatched', [
'server_id' => $server->id,
'server_name' => $server->name,
'team_id' => $server->team_id,
]);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
@@ -296,19 +404,28 @@ class ScheduledJobManager implements ShouldQueue
return $query->get();
}
private function shouldProcessDockerCleanup(Server $server): bool
private function getDockerCleanupSkipReason(Server $server): ?string
{
if (! $server->isFunctional()) {
return false;
return 'server_not_functional';
}
// In cloud, check subscription status (except team 0)
if (isCloud() && $server->team_id !== 0) {
if (data_get($server->team->subscription, 'stripe_invoice_paid', false) === false) {
return false;
return 'subscription_unpaid';
}
}
return true;
return null;
}
private function logSkip(string $type, string $reason, array $context = []): void
{
Log::channel('scheduled')->info(ucfirst(str_replace('_', ' ', $type)).' skipped', array_merge([
'type' => $type,
'skip_reason' => $reason,
'execution_time' => $this->executionTime?->toIso8601String(),
], $context));
}
}
+2 -1
View File
@@ -14,13 +14,14 @@ use App\Notifications\ScheduledTask\TaskFailed;
use App\Notifications\ScheduledTask\TaskSuccess;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ScheduledTaskJob implements ShouldQueue
class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+12 -1
View File
@@ -4,16 +4,27 @@ namespace App\Jobs;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class SendMessageToSlackJob implements ShouldQueue
class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*/
public $backoff = 10;
public function __construct(
private SlackMessage $message,
private string $webhookUrl
+5
View File
@@ -22,6 +22,11 @@ class SendMessageToTelegramJob implements ShouldBeEncrypted, ShouldQueue
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*/
public $backoff = 10;
/**
* The maximum number of unhandled exceptions to allow before failing.
*/
+16
View File
@@ -9,6 +9,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -40,6 +42,20 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
*/
public function handle(): void
{
$validator = Validator::make(
['webhook_url' => $this->webhookUrl],
['webhook_url' => ['required', 'url', new \App\Rules\SafeWebhookUrl]]
);
if ($validator->fails()) {
Log::warning('SendWebhookJob: blocked unsafe webhook URL', [
'url' => $this->webhookUrl,
'errors' => $validator->errors()->all(),
]);
return;
}
if (isDev()) {
ray('Sending webhook notification', [
'url' => $this->webhookUrl,
+14
View File
@@ -15,6 +15,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -33,6 +34,19 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
public function __construct(public Server $server) {}
public function failed(?\Throwable $exception): void
{
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
Log::warning('ServerCheckJob timed out', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
]);
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
}
}
public function handle()
{
try {
+18 -6
View File
@@ -101,22 +101,34 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
'is_usable' => false,
]);
throw $e;
return;
}
}
public function failed(?\Throwable $exception): void
{
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
]);
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
}
}
private function checkHetznerStatus(): void
{
$status = null;
try {
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$status = $serverData['status'] ?? null;
} catch (\Throwable $e) {
Log::debug('ServerConnectionCheck: Hetzner status check failed', [
'server_id' => $this->server->id,
'error' => $e->getMessage(),
]);
} catch (\Throwable) {
// Silently ignore — server may have been deleted from Hetzner.
}
if ($this->server->hetzner_server_status !== $status) {
$this->server->update(['hetzner_server_status' => $status]);
+1 -1
View File
@@ -38,7 +38,7 @@ class ServerLimitCheckJob implements ShouldBeEncrypted, ShouldQueue
$server->forceDisableServer();
$this->team->notify(new ForceDisabled($server));
});
} elseif ($number_of_servers_to_disable === 0) {
} elseif ($number_of_servers_to_disable <= 0) {
$servers->each(function ($server) {
if ($server->isForceDisabled()) {
$server->forceEnableServer();
+16 -28
View File
@@ -5,8 +5,8 @@ namespace App\Jobs;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use Cron\CronExpression;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
@@ -15,7 +15,7 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class ServerManagerJob implements ShouldQueue
class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
@@ -64,11 +64,11 @@ class ServerManagerJob implements ShouldQueue
private function getServers(): Collection
{
$allServers = Server::where('ip', '!=', '1.2.3.4');
$allServers = Server::with('settings')->where('ip', '!=', '1.2.3.4');
if (isCloud()) {
$servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
$own = Team::find(0)->servers;
$own = Team::find(0)->servers()->with('settings')->get();
return $servers->merge($own);
} else {
@@ -79,9 +79,13 @@ class ServerManagerJob implements ShouldQueue
private function dispatchConnectionChecks(Collection $servers): void
{
if ($this->shouldRunNow($this->checkFrequency)) {
if (shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-connection-checks', $this->executionTime)) {
$servers->each(function (Server $server) {
try {
// Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity
if ($server->isSentinelEnabled() && $server->isSentinelLive()) {
return;
}
ServerConnectionCheckJob::dispatch($server);
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [
@@ -124,19 +128,17 @@ class ServerManagerJob implements ShouldQueue
if ($sentinelOutOfSync) {
// Dispatch ServerCheckJob if Sentinel is out of sync
if ($this->shouldRunNow($this->checkFrequency, $serverTimezone)) {
if (shouldRunCronNow($this->checkFrequency, $serverTimezone, "server-check:{$server->id}", $this->executionTime)) {
ServerCheckJob::dispatch($server);
}
}
$isSentinelEnabled = $server->isSentinelEnabled();
$shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone);
$shouldRestartSentinel = $isSentinelEnabled && shouldRunCronNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}", $this->executionTime);
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
if ($shouldRestartSentinel) {
dispatch(function () use ($server) {
$server->restartContainer('coolify-sentinel');
});
CheckAndStartSentinelJob::dispatch($server);
}
// Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled)
@@ -146,7 +148,7 @@ class ServerManagerJob implements ShouldQueue
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
}
$shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone);
$shouldRunStorageCheck = shouldRunCronNow($serverDiskUsageCheckFrequency, $serverTimezone, "server-storage-check:{$server->id}", $this->executionTime);
if ($shouldRunStorageCheck) {
ServerStorageCheckJob::dispatch($server);
@@ -154,27 +156,13 @@ class ServerManagerJob implements ShouldQueue
}
// Dispatch ServerPatchCheckJob if due (weekly)
$shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone);
$shouldRunPatchCheck = shouldRunCronNow('0 0 * * 0', $serverTimezone, "server-patch-check:{$server->id}", $this->executionTime);
if ($shouldRunPatchCheck) { // Weekly on Sunday at midnight
ServerPatchCheckJob::dispatch($server);
}
// Sentinel update checks (hourly) - check for updates to Sentinel version
// No timezone needed for hourly - runs at top of every hour
if ($isSentinelEnabled && $this->shouldRunNow('0 * * * *')) {
CheckAndStartSentinelJob::dispatch($server);
}
}
private function shouldRunNow(string $frequency, ?string $timezone = null): bool
{
$cron = new CronExpression($frequency);
// Use the frozen execution time, not the current time
$baseTime = $this->executionTime ?? Carbon::now();
$executionTime = $baseTime->copy()->setTimezone($timezone ?? config('app.timezone'));
return $cron->isDue($executionTime);
// Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates.
// Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob.
}
}
+14
View File
@@ -10,6 +10,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Laravel\Horizon\Contracts\Silenced;
@@ -28,6 +29,19 @@ class ServerStorageCheckJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public function __construct(public Server $server, public int|string|null $percentage = null) {}
public function failed(?\Throwable $exception): void
{
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
Log::warning('ServerStorageCheckJob timed out', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
]);
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
}
}
public function handle()
{
try {
+24 -36
View File
@@ -2,13 +2,15 @@
namespace App\Jobs;
use App\Actions\Stripe\UpdateSubscriptionQuantity;
use App\Models\Subscription;
use App\Models\Team;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Str;
class StripeProcessJob implements ShouldQueue
class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
{
use Queueable;
@@ -71,25 +73,15 @@ class StripeProcessJob implements ShouldQueue
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}.");
}
$subscription = Subscription::where('team_id', $teamId)->first();
if ($subscription) {
// send_internal_notification('Old subscription activated for team: '.$teamId);
$subscription->update([
Subscription::updateOrCreate(
['team_id' => $teamId],
[
'stripe_subscription_id' => $subscriptionId,
'stripe_customer_id' => $customerId,
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
]);
} else {
// send_internal_notification('New subscription for team: '.$teamId);
Subscription::create([
'team_id' => $teamId,
'stripe_subscription_id' => $subscriptionId,
'stripe_customer_id' => $customerId,
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
]);
}
]
);
break;
case 'invoice.paid':
$customerId = data_get($data, 'customer');
@@ -225,18 +217,15 @@ class StripeProcessJob implements ShouldQueue
// send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}.");
}
$subscription = Subscription::where('team_id', $teamId)->first();
if ($subscription) {
// send_internal_notification("Subscription already exists for team: {$teamId}");
throw new \RuntimeException("Subscription already exists for team: {$teamId}");
} else {
Subscription::create([
'team_id' => $teamId,
Subscription::updateOrCreate(
['team_id' => $teamId],
[
'stripe_subscription_id' => $subscriptionId,
'stripe_customer_id' => $customerId,
'stripe_invoice_paid' => false,
]);
}
]
);
break;
case 'customer.subscription.updated':
$teamId = data_get($data, 'metadata.team_id');
$userId = data_get($data, 'metadata.user_id');
@@ -251,34 +240,33 @@ class StripeProcessJob implements ShouldQueue
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
if ($status === 'incomplete_expired') {
// send_internal_notification('Subscription incomplete expired');
throw new \RuntimeException('Subscription incomplete expired');
}
if ($teamId) {
$subscription = Subscription::create([
'team_id' => $teamId,
if (! $teamId) {
throw new \RuntimeException('No subscription and team id found');
}
$subscription = Subscription::firstOrCreate(
['team_id' => $teamId],
[
'stripe_subscription_id' => $subscriptionId,
'stripe_customer_id' => $customerId,
'stripe_invoice_paid' => false,
]);
} else {
// send_internal_notification('No subscription and team id found');
throw new \RuntimeException('No subscription and team id found');
}
]
);
}
$cancelAtPeriodEnd = data_get($data, 'cancel_at_period_end');
$feedback = data_get($data, 'cancellation_details.feedback');
$comment = data_get($data, 'cancellation_details.comment');
$lookup_key = data_get($data, 'items.data.0.price.lookup_key');
if (str($lookup_key)->contains('dynamic')) {
$quantity = data_get($data, 'items.data.0.quantity', 2);
$quantity = min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT);
$team = data_get($subscription, 'team');
if ($team) {
$team->update([
'custom_server_limit' => $quantity,
]);
ServerLimitCheckJob::dispatch($team);
}
ServerLimitCheckJob::dispatch($team);
}
$subscription->update([
'stripe_feedback' => $feedback,
+134 -28
View File
@@ -4,12 +4,13 @@ namespace App\Jobs;
use App\Models\Subscription;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SyncStripeSubscriptionsJob implements ShouldQueue
class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
@@ -22,7 +23,7 @@ class SyncStripeSubscriptionsJob implements ShouldQueue
$this->onQueue('high');
}
public function handle(): array
public function handle(?\Closure $onProgress = null): array
{
if (! isCloud() || ! isStripe()) {
return ['error' => 'Not running on Cloud or Stripe not configured'];
@@ -33,48 +34,73 @@ class SyncStripeSubscriptionsJob implements ShouldQueue
->get();
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
// Bulk fetch all valid subscription IDs from Stripe (active + past_due)
$validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress);
// Find DB subscriptions not in the valid set
$staleSubscriptions = $subscriptions->filter(
fn (Subscription $sub) => ! in_array($sub->stripe_subscription_id, $validStripeIds)
);
// For each stale subscription, get the exact Stripe status and check for resubscriptions
$discrepancies = [];
$resubscribed = [];
$errors = [];
foreach ($subscriptions as $subscription) {
foreach ($staleSubscriptions as $subscription) {
try {
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
$stripeStatus = $stripeSubscription->status;
// Check if Stripe says cancelled but we think it's active
if (in_array($stripeSubscription->status, ['canceled', 'incomplete_expired', 'unpaid'])) {
$discrepancies[] = [
'subscription_id' => $subscription->id,
'team_id' => $subscription->team_id,
'stripe_subscription_id' => $subscription->stripe_subscription_id,
'stripe_status' => $stripeSubscription->status,
];
// Only fix if --fix flag is passed
if ($this->fix) {
$subscription->update([
'stripe_invoice_paid' => false,
'stripe_past_due' => false,
]);
if ($stripeSubscription->status === 'canceled') {
$subscription->team?->subscriptionEnded();
}
}
}
// Small delay to avoid Stripe rate limits
usleep(100000); // 100ms
usleep(100000); // 100ms rate limit delay
} catch (\Exception $e) {
$errors[] = [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
];
continue;
}
// Check if this user resubscribed under a different customer/subscription
$activeSub = $this->findActiveSubscriptionByEmail($stripe, $stripeSubscription->customer);
if ($activeSub) {
$resubscribed[] = [
'subscription_id' => $subscription->id,
'team_id' => $subscription->team_id,
'email' => $activeSub['email'],
'old_stripe_subscription_id' => $subscription->stripe_subscription_id,
'old_stripe_customer_id' => $stripeSubscription->customer,
'new_stripe_subscription_id' => $activeSub['subscription_id'],
'new_stripe_customer_id' => $activeSub['customer_id'],
'new_status' => $activeSub['status'],
];
continue;
}
$discrepancies[] = [
'subscription_id' => $subscription->id,
'team_id' => $subscription->team_id,
'stripe_subscription_id' => $subscription->stripe_subscription_id,
'stripe_status' => $stripeStatus,
];
if ($this->fix) {
$subscription->update([
'stripe_invoice_paid' => false,
'stripe_past_due' => false,
]);
if ($stripeStatus === 'canceled') {
$subscription->team?->subscriptionEnded();
}
}
}
// Only notify if discrepancies found and fixed
if ($this->fix && count($discrepancies) > 0) {
send_internal_notification(
'SyncStripeSubscriptionsJob: Fixed '.count($discrepancies)." discrepancies:\n".
@@ -85,8 +111,88 @@ class SyncStripeSubscriptionsJob implements ShouldQueue
return [
'total_checked' => $subscriptions->count(),
'discrepancies' => $discrepancies,
'resubscribed' => $resubscribed,
'errors' => $errors,
'fixed' => $this->fix,
];
}
/**
* Given a Stripe customer ID, get their email and search for other customers
* with the same email that have an active subscription.
*
* @return array{email: string, customer_id: string, subscription_id: string, status: string}|null
*/
private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, string $customerId): ?array
{
try {
$customer = $stripe->customers->retrieve($customerId);
$email = $customer->email;
if (! $email) {
return null;
}
usleep(100000);
$customers = $stripe->customers->all([
'email' => $email,
'limit' => 10,
]);
usleep(100000);
foreach ($customers->data as $matchingCustomer) {
if ($matchingCustomer->id === $customerId) {
continue;
}
$subs = $stripe->subscriptions->all([
'customer' => $matchingCustomer->id,
'limit' => 10,
]);
usleep(100000);
foreach ($subs->data as $sub) {
if (in_array($sub->status, ['active', 'past_due'])) {
return [
'email' => $email,
'customer_id' => $matchingCustomer->id,
'subscription_id' => $sub->id,
'status' => $sub->status,
];
}
}
}
} catch (\Exception $e) {
// Silently skip — will fall through to normal discrepancy
}
return null;
}
/**
* Bulk fetch all active and past_due subscription IDs from Stripe.
*
* @return array<string>
*/
private function fetchValidStripeSubscriptionIds(\Stripe\StripeClient $stripe, ?\Closure $onProgress = null): array
{
$validIds = [];
$fetched = 0;
foreach (['active', 'past_due'] as $status) {
foreach ($stripe->subscriptions->all(['status' => $status, 'limit' => 100])->autoPagingIterator() as $sub) {
$validIds[] = $sub->id;
$fetched++;
if ($onProgress) {
$onProgress($fetched);
}
}
}
return $validIds;
}
}
+8 -3
View File
@@ -8,13 +8,14 @@ use App\Events\ServerReachabilityChanged;
use App\Events\ServerValidated;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ValidateAndInstallServerJob implements ShouldQueue
class ValidateAndInstallServerJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
@@ -44,7 +45,8 @@ class ValidateAndInstallServerJob implements ShouldQueue
// Validate connection
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
if (! $uptime) {
$errorMessage = 'Server is not reachable. Please validate your configuration and connection.<br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br>Error: '.$error;
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
$errorMessage = 'Server is not reachable. Please validate your configuration and connection.<br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br>Error: '.$sanitizedError;
$this->server->update([
'validation_logs' => $errorMessage,
'is_validating' => false,
@@ -178,6 +180,9 @@ class ValidateAndInstallServerJob implements ShouldQueue
// Mark validation as complete
$this->server->update(['is_validating' => false]);
// Auto-fetch server details now that validation passed
$this->server->gatherServerMetadata();
// Refresh server to get latest state
$this->server->refresh();
@@ -193,7 +198,7 @@ class ValidateAndInstallServerJob implements ShouldQueue
]);
$this->server->update([
'validation_logs' => 'An error occurred during validation: '.$e->getMessage(),
'validation_logs' => 'An error occurred during validation: '.htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'),
'is_validating' => false,
]);
}
@@ -4,12 +4,13 @@ namespace App\Jobs;
use App\Models\Subscription;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class VerifyStripeSubscriptionStatusJob implements ShouldQueue
class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
@@ -81,12 +82,9 @@ class VerifyStripeSubscriptionStatusJob implements ShouldQueue
'stripe_past_due' => false,
]);
// Trigger subscription ended logic if canceled
if ($stripeSubscription->status === 'canceled') {
$team = $this->subscription->team;
if ($team) {
$team->subscriptionEnded();
}
$team = $this->subscription->team;
if ($team) {
$team->subscriptionEnded();
}
break;
+44 -8
View File
@@ -2,7 +2,9 @@
namespace App\Livewire;
use App\Models\Server;
use App\Models\User;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
@@ -10,6 +12,7 @@ class ActivityMonitor extends Component
{
public ?string $header = null;
#[Locked]
public $activityId = null;
public $eventToDispatch = 'activityFinished';
@@ -55,16 +58,49 @@ class ActivityMonitor extends Component
return;
}
$this->activity = Activity::find($this->activityId);
}
$activity = Activity::find($this->activityId);
public function updatedActivityId($value)
{
if ($value) {
$this->hydrateActivity();
$this->isPollingActive = true;
self::$eventDispatched = false;
if (! $activity) {
$this->activity = null;
return;
}
$currentTeamId = currentTeam()?->id;
// Check team_id stored directly in activity properties
$activityTeamId = data_get($activity, 'properties.team_id');
if ($activityTeamId !== null) {
if ((int) $activityTeamId !== (int) $currentTeamId) {
$this->activity = null;
return;
}
$this->activity = $activity;
return;
}
// Fallback: verify ownership via the server that ran the command
$serverUuid = data_get($activity, 'properties.server_uuid');
if ($serverUuid) {
$server = Server::where('uuid', $serverUuid)->first();
if ($server && (int) $server->team_id !== (int) $currentTeamId) {
$this->activity = null;
return;
}
if ($server) {
$this->activity = $activity;
return;
}
}
// Fail closed: no team_id and no server_uuid means we cannot verify ownership
$this->activity = null;
}
public function polling()
+22 -9
View File
@@ -6,7 +6,6 @@ use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
class Index extends Component
@@ -22,16 +21,15 @@ class Index extends Component
public function mount()
{
if (! isCloud() && ! isDev()) {
return redirect()->route('dashboard');
}
if (Auth::id() !== 0 && ! session('impersonating')) {
return redirect()->route('dashboard');
abort(403);
}
$this->authorizeAdminAccess();
$this->getSubscribers();
}
public function back()
{
$this->authorizeAdminAccess();
if (session('impersonating')) {
session()->forget('impersonating');
$user = User::find(0);
@@ -45,6 +43,7 @@ class Index extends Component
public function submitSearch()
{
$this->authorizeAdminAccess();
if ($this->search !== '') {
$this->foundUsers = User::where(function ($query) {
$query->where('name', 'like', "%{$this->search}%")
@@ -61,19 +60,33 @@ class Index extends Component
public function switchUser(int $user_id)
{
if (Auth::id() !== 0) {
return redirect()->route('dashboard');
}
$this->authorizeRootOnly();
session(['impersonating' => true]);
$user = User::find($user_id);
if (! $user) {
abort(404);
}
$team_to_switch_to = $user->teams->first();
// Cache::forget("team:{$user->id}");
Auth::login($user);
refreshSession($team_to_switch_to);
return redirect(request()->header('Referer'));
}
private function authorizeAdminAccess(): void
{
if (! Auth::check() || (Auth::id() !== 0 && ! session('impersonating'))) {
abort(403);
}
}
private function authorizeRootOnly(): void
{
if (! Auth::check() || Auth::id() !== 0) {
abort(403);
}
}
public function render()
{
return view('livewire.admin.index');
+11 -4
View File
@@ -121,7 +121,7 @@ class Index extends Component
}
if ($this->selectedExistingServer) {
$this->createdServer = Server::find($this->selectedExistingServer);
$this->createdServer = Server::ownedByCurrentTeam()->find($this->selectedExistingServer);
if ($this->createdServer) {
$this->serverPublicKey = $this->createdServer->privateKey->getPublicKey();
$this->updateServerDetails();
@@ -145,7 +145,7 @@ class Index extends Component
}
if ($this->selectedProject) {
$this->createdProject = Project::find($this->selectedProject);
$this->createdProject = Project::ownedByCurrentTeam()->find($this->selectedProject);
if (! $this->createdProject) {
$this->projects = Project::ownedByCurrentTeam(['name'])->get();
}
@@ -283,7 +283,11 @@ class Index extends Component
$this->privateKey = formatPrivateKey($this->privateKey);
$foundServer = Server::whereIp($this->remoteServerHost)->first();
if ($foundServer) {
return $this->dispatch('error', 'IP address is already in use by another team.');
if ($foundServer->team_id === currentTeam()->id) {
return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.');
}
return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.');
}
$this->createdServer = Server::create([
'name' => $this->remoteServerName,
@@ -427,7 +431,10 @@ class Index extends Component
public function selectExistingProject()
{
$this->createdProject = Project::find($this->selectedProject);
$this->createdProject = Project::ownedByCurrentTeam()->find($this->selectedProject);
if (! $this->createdProject) {
return $this->dispatch('error', 'Project not found.');
}
$this->currentState = 'create-resource';
}
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Destination\New;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
@@ -24,7 +25,7 @@ class Docker extends Component
#[Validate(['required', 'string'])]
public string $name;
#[Validate(['required', 'string'])]
#[Validate(['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'])]
public string $network;
#[Validate(['required', 'string'])]
+4 -3
View File
@@ -20,7 +20,7 @@ class Show extends Component
#[Validate(['string', 'required'])]
public string $name;
#[Validate(['string', 'required'])]
#[Validate(['string', 'required', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'])]
public string $network;
#[Validate(['string', 'required'])]
@@ -84,8 +84,9 @@ class Show extends Component
if ($this->destination->attachedTo()) {
return $this->dispatch('error', 'You must delete all resources before deleting this destination.');
}
instant_remote_process(["docker network disconnect {$this->destination->network} coolify-proxy"], $this->destination->server, throwError: false);
instant_remote_process(['docker network rm -f '.$this->destination->network], $this->destination->server);
$safeNetwork = escapeshellarg($this->destination->network);
instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $this->destination->server, throwError: false);
instant_remote_process(["docker network rm -f {$safeNetwork}"], $this->destination->server);
}
$this->destination->delete();
+3 -2
View File
@@ -1203,7 +1203,7 @@ class GlobalSearch extends Component
public function loadDestinations()
{
$this->loadingDestinations = true;
$server = Server::find($this->selectedServerId);
$server = Server::ownedByCurrentTeam()->find($this->selectedServerId);
if (! $server) {
$this->loadingDestinations = false;
@@ -1280,7 +1280,7 @@ class GlobalSearch extends Component
public function loadEnvironments()
{
$this->loadingEnvironments = true;
$project = Project::where('uuid', $this->selectedProjectUuid)->first();
$project = Project::ownedByCurrentTeam()->where('uuid', $this->selectedProjectUuid)->first();
if (! $project) {
$this->loadingEnvironments = false;
@@ -1495,6 +1495,7 @@ class GlobalSearch extends Component
'type' => 'one-click-service-'.$serviceKey,
'category' => 'Services',
'resourceType' => 'service',
'logo' => data_get($service, 'logo'),
]);
}
+2 -2
View File
@@ -15,10 +15,10 @@ class NavbarDeleteTeam extends Component
$this->team = currentTeam()->name;
}
public function delete($password)
public function delete($password, $selectedActions = [])
{
if (! verifyPasswordConfirmation($password, $this)) {
return;
return 'The provided password is incorrect.';
}
$currentTeam = currentTeam();
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Notifications;
use App\Models\DiscordNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
use App\Rules\SafeWebhookUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -20,7 +21,7 @@ class Discord extends Component
#[Validate(['boolean'])]
public bool $discordEnabled = false;
#[Validate(['url', 'nullable'])]
#[Validate(['nullable', new SafeWebhookUrl])]
public ?string $discordWebhookUrl = null;
#[Validate(['boolean'])]
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Notifications;
use App\Models\SlackNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
use App\Rules\SafeWebhookUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
@@ -25,7 +26,7 @@ class Slack extends Component
#[Validate(['boolean'])]
public bool $slackEnabled = false;
#[Validate(['url', 'nullable'])]
#[Validate(['nullable', new SafeWebhookUrl])]
public ?string $slackWebhookUrl = null;
#[Validate(['boolean'])]
+2 -1
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Notifications;
use App\Models\Team;
use App\Models\WebhookNotificationSettings;
use App\Notifications\Test;
use App\Rules\SafeWebhookUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -20,7 +21,7 @@ class Webhook extends Component
#[Validate(['boolean'])]
public bool $webhookEnabled = false;
#[Validate(['url', 'nullable'])]
#[Validate(['nullable', new SafeWebhookUrl])]
public ?string $webhookUrl = null;
#[Validate(['boolean'])]
@@ -51,9 +51,7 @@ class Configuration extends Component
$this->environment = $environment;
$this->application = $application;
if ($this->application->deploymentType() === 'deploy_key' && $this->currentRoute === 'project.application.preview-deployments') {
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);
}
if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') {
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);
+41 -66
View File
@@ -3,12 +3,14 @@
namespace App\Livewire\Project\Application;
use App\Actions\Application\GenerateConfig;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Support\ValidationPatterns;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\Features\SupportEvents\Event;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
@@ -22,136 +24,95 @@ class General extends Component
public Collection $services;
#[Validate('required|regex:/^[a-zA-Z0-9\s\-_.\/:()]+$/')]
public string $name;
#[Validate(['string', 'nullable'])]
public ?string $description = null;
#[Validate(['nullable'])]
public ?string $fqdn = null;
#[Validate(['required'])]
public string $gitRepository;
#[Validate(['required'])]
public string $gitBranch;
#[Validate(['string', 'nullable'])]
public ?string $gitCommitSha = null;
#[Validate(['string', 'nullable'])]
public ?string $installCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $buildCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $startCommand = null;
#[Validate(['required'])]
public string $buildPack;
#[Validate(['required'])]
public string $staticImage;
#[Validate(['required'])]
public string $baseDirectory;
#[Validate(['string', 'nullable'])]
public ?string $publishDirectory = null;
#[Validate(['string', 'nullable'])]
public ?string $portsExposes = null;
#[Validate(['string', 'nullable'])]
public ?string $portsMappings = null;
#[Validate(['string', 'nullable'])]
public ?string $customNetworkAliases = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerfile = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerfileLocation = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerfileTargetBuild = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageName = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageTag = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerComposeLocation = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerCompose = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerComposeRaw = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerComposeCustomStartCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerComposeCustomBuildCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $customDockerRunOptions = null;
#[Validate(['string', 'nullable'])]
// Security: pre/post deployment commands are intentionally arbitrary shell — users need full
// flexibility (e.g. "php artisan migrate"). Access is gated by team authentication/authorization.
// Commands execute inside the application's own container, not on the host.
public ?string $preDeploymentCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $preDeploymentCommandContainer = null;
#[Validate(['string', 'nullable'])]
public ?string $postDeploymentCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $postDeploymentCommandContainer = null;
#[Validate(['string', 'nullable'])]
public ?string $customNginxConfiguration = null;
#[Validate(['boolean', 'required'])]
public bool $isStatic = false;
#[Validate(['boolean', 'required'])]
public bool $isSpa = false;
#[Validate(['boolean', 'required'])]
public bool $isBuildServerEnabled = false;
#[Validate(['boolean', 'required'])]
public bool $isPreserveRepositoryEnabled = false;
#[Validate(['boolean', 'required'])]
public bool $isContainerLabelEscapeEnabled = true;
#[Validate(['boolean', 'required'])]
public bool $isContainerLabelReadonlyEnabled = false;
#[Validate(['boolean', 'required'])]
public bool $isHttpBasicAuthEnabled = false;
#[Validate(['string', 'nullable'])]
public ?string $httpBasicAuthUsername = null;
#[Validate(['string', 'nullable'])]
public ?string $httpBasicAuthPassword = null;
#[Validate(['nullable'])]
public ?string $watchPaths = null;
#[Validate(['string', 'required'])]
public string $redirect;
#[Validate(['nullable'])]
public $customLabels;
public bool $labelsChanged = false;
@@ -184,33 +145,33 @@ class General extends Component
'fqdn' => 'nullable',
'gitRepository' => 'required',
'gitBranch' => 'required',
'gitCommitSha' => 'nullable',
'installCommand' => 'nullable',
'buildCommand' => 'nullable',
'startCommand' => 'nullable',
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'installCommand' => ValidationPatterns::shellSafeCommandRules(),
'buildCommand' => ValidationPatterns::shellSafeCommandRules(),
'startCommand' => ValidationPatterns::shellSafeCommandRules(),
'buildPack' => 'required',
'staticImage' => 'required',
'baseDirectory' => 'required',
'publishDirectory' => 'nullable',
'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)),
'publishDirectory' => ValidationPatterns::directoryPathRules(),
'portsExposes' => 'required',
'portsMappings' => 'nullable',
'customNetworkAliases' => 'nullable',
'dockerfile' => 'nullable',
'dockerRegistryImageName' => 'nullable',
'dockerRegistryImageTag' => 'nullable',
'dockerfileLocation' => 'nullable',
'dockerComposeLocation' => 'nullable',
'dockerfileLocation' => ValidationPatterns::filePathRules(),
'dockerComposeLocation' => ValidationPatterns::filePathRules(),
'dockerCompose' => 'nullable',
'dockerComposeRaw' => 'nullable',
'dockerfileTargetBuild' => 'nullable',
'dockerComposeCustomStartCommand' => 'nullable',
'dockerComposeCustomBuildCommand' => 'nullable',
'dockerfileTargetBuild' => ValidationPatterns::dockerTargetRules(),
'dockerComposeCustomStartCommand' => ValidationPatterns::shellSafeCommandRules(),
'dockerComposeCustomBuildCommand' => ValidationPatterns::shellSafeCommandRules(),
'customLabels' => 'nullable',
'customDockerRunOptions' => 'nullable',
'customDockerRunOptions' => ValidationPatterns::shellSafeCommandRules(2000),
'preDeploymentCommand' => 'nullable',
'preDeploymentCommandContainer' => 'nullable',
'preDeploymentCommandContainer' => ['nullable', ...ValidationPatterns::containerNameRules()],
'postDeploymentCommand' => 'nullable',
'postDeploymentCommandContainer' => 'nullable',
'postDeploymentCommandContainer' => ['nullable', ...ValidationPatterns::containerNameRules()],
'customNginxConfiguration' => 'nullable',
'isStatic' => 'boolean|required',
'isSpa' => 'boolean|required',
@@ -231,6 +192,19 @@ class General extends Component
return array_merge(
ValidationPatterns::combinedMessages(),
[
...ValidationPatterns::filePathMessages('dockerfileLocation', 'Dockerfile'),
...ValidationPatterns::filePathMessages('dockerComposeLocation', 'Docker Compose'),
'baseDirectory.regex' => 'The base directory must be a valid path starting with / and containing only safe characters.',
'publishDirectory.regex' => 'The publish directory must be a valid path starting with / and containing only safe characters.',
'dockerfileTargetBuild.regex' => 'The Dockerfile target build must contain only alphanumeric characters, dots, hyphens, and underscores.',
'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
'installCommand.regex' => 'The install command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
'buildCommand.regex' => 'The build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
'startCommand.regex' => 'The start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
'name.required' => 'The Name field is required.',
'gitRepository.required' => 'The Git Repository field is required.',
'gitBranch.required' => 'The Git Branch field is required.',
@@ -320,7 +294,7 @@ class General extends Component
$this->authorize('update', $this->application);
$this->application->fqdn = null;
$this->application->settings->save();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User doesn't have update permission, just continue without saving
}
}
@@ -341,7 +315,7 @@ class General extends Component
$this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
$this->application->custom_labels = base64_encode($this->customLabels);
$this->application->save();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User doesn't have update permission, just use existing labels
// $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
}
@@ -353,7 +327,7 @@ class General extends Component
$this->authorize('update', $this->application);
$this->initLoadingCompose = true;
$this->dispatch('info', 'Loading docker compose file.');
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User doesn't have update permission, skip loading compose file
}
}
@@ -619,7 +593,7 @@ class General extends Component
// Check if user has permission to update
try {
$this->authorize('update', $this->application);
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User doesn't have permission, revert the change and return
$this->application->refresh();
$this->syncData();
@@ -644,7 +618,7 @@ class General extends Component
$this->fqdn = null;
$this->application->fqdn = null;
$this->application->settings->save();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User doesn't have update permission, just continue without saving
}
}
@@ -761,6 +735,7 @@ class General extends Component
$this->authorize('update', $this->application);
try {
$this->application->redirect = $this->redirect;
$has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count();
if ($has_www === 0 && $this->application->redirect === 'www') {
$this->dispatch('error', 'You want to redirect to www, but you do not have a www domain set.<br><br>Please add www to your domain list and as an A DNS record (if applicable).');
@@ -841,7 +816,7 @@ class General extends Component
restoreBaseDirectory: $oldBaseDirectory,
restoreDockerComposeLocation: $oldDockerComposeLocation
);
if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) {
if ($compose_return instanceof Event) {
// Validation failed - restore original values to component properties
$this->baseDirectory = $oldBaseDirectory;
$this->dockerComposeLocation = $oldDockerComposeLocation;
@@ -971,7 +946,7 @@ class General extends Component
$command = injectDockerComposeFlags(
$this->dockerComposeCustomBuildCommand,
".{$normalizedBase}{$this->dockerComposeLocation}",
\App\Jobs\ApplicationDeploymentJob::BUILD_TIME_ENV_PATH
ApplicationDeploymentJob::BUILD_TIME_ENV_PATH
);
// Inject build args if not using build secrets
@@ -50,6 +50,8 @@ class Rollback extends Component
{
$this->authorize('deploy', $this->application);
$commit = validateGitRef($commit, 'rollback commit');
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
+1 -1
View File
@@ -30,7 +30,7 @@ class Source extends Component
#[Validate(['required', 'string'])]
public string $gitBranch;
#[Validate(['nullable', 'string'])]
#[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]
public ?string $gitCommitSha = null;
#[Locked]
+18 -18
View File
@@ -54,7 +54,7 @@ class CloneMe extends Component
public function mount($project_uuid)
{
$this->project_uuid = $project_uuid;
$this->project = Project::where('uuid', $project_uuid)->firstOrFail();
$this->project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail();
$this->environment = $this->project->environments->where('uuid', $this->environment_uuid)->first();
$this->project_id = $this->project->id;
$this->servers = currentTeam()
@@ -139,7 +139,7 @@ class CloneMe extends Component
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'uuid' => $uuid,
'status' => 'exited',
'started_at' => null,
@@ -187,7 +187,7 @@ class CloneMe extends Component
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'name' => $newName,
'resource_id' => $newDatabase->id,
]);
@@ -216,7 +216,7 @@ class CloneMe extends Component
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'resource_id' => $newDatabase->id,
]);
$newStorage->save();
@@ -229,7 +229,7 @@ class CloneMe extends Component
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'uuid' => $uuid,
'database_id' => $newDatabase->id,
'database_type' => $newDatabase->getMorphClass(),
@@ -247,7 +247,7 @@ class CloneMe extends Component
'id',
'created_at',
'updated_at',
])->fill($payload);
])->forceFill($payload);
$newEnvironmentVariable->save();
}
}
@@ -258,7 +258,7 @@ class CloneMe extends Component
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'uuid' => $uuid,
'environment_id' => $environment->id,
'destination_id' => $this->selectedDestination,
@@ -276,7 +276,7 @@ class CloneMe extends Component
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'uuid' => (string) new Cuid2,
'service_id' => $newService->id,
'team_id' => currentTeam()->id,
@@ -290,7 +290,7 @@ class CloneMe extends Component
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'resourceable_id' => $newService->id,
'resourceable_type' => $newService->getMorphClass(),
]);
@@ -298,9 +298,9 @@ class CloneMe extends Component
}
foreach ($newService->applications() as $application) {
$application->update([
$application->forceFill([
'status' => 'exited',
]);
])->save();
$persistentVolumes = $application->persistentStorages()->get();
foreach ($persistentVolumes as $volume) {
@@ -315,7 +315,7 @@ class CloneMe extends Component
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'name' => $newName,
'resource_id' => $application->id,
]);
@@ -344,7 +344,7 @@ class CloneMe extends Component
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'resource_id' => $application->id,
]);
$newStorage->save();
@@ -352,9 +352,9 @@ class CloneMe extends Component
}
foreach ($newService->databases() as $database) {
$database->update([
$database->forceFill([
'status' => 'exited',
]);
])->save();
$persistentVolumes = $database->persistentStorages()->get();
foreach ($persistentVolumes as $volume) {
@@ -369,7 +369,7 @@ class CloneMe extends Component
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'name' => $newName,
'resource_id' => $database->id,
]);
@@ -398,7 +398,7 @@ class CloneMe extends Component
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'resource_id' => $database->id,
]);
$newStorage->save();
@@ -411,7 +411,7 @@ class CloneMe extends Component
'id',
'created_at',
'updated_at',
])->fill([
])->forceFill([
'uuid' => $uuid,
'database_id' => $database->id,
'database_type' => $database->getMorphClass(),
+4 -16
View File
@@ -105,21 +105,9 @@ class BackupEdit extends Component
$this->backup->s3_storage_id = $this->s3StorageId;
// Validate databases_to_backup to prevent command injection
// Handles all formats including MongoDB's "db:col1,col2|db2:col3"
if (filled($this->databasesToBackup)) {
$databases = str($this->databasesToBackup)->explode(',');
foreach ($databases as $index => $db) {
$dbName = trim($db);
try {
validateShellSafePath($dbName, 'database name');
} catch (\Exception $e) {
// Provide specific error message indicating which database failed validation
$position = $index + 1;
throw new \Exception(
"Database #{$position} ('{$dbName}') validation failed: ".
$e->getMessage()
);
}
}
validateDatabasesBackupInput($this->databasesToBackup);
}
$this->backup->databases_to_backup = $this->databasesToBackup;
@@ -146,12 +134,12 @@ class BackupEdit extends Component
}
}
public function delete($password)
public function delete($password, $selectedActions = [])
{
$this->authorize('manageBackups', $this->backup->database);
if (! verifyPasswordConfirmation($password, $this)) {
return;
return 'The provided password is incorrect.';
}
try {
@@ -65,10 +65,10 @@ class BackupExecutions extends Component
}
}
public function deleteBackup($executionId, $password)
public function deleteBackup($executionId, $password, $selectedActions = [])
{
if (! verifyPasswordConfirmation($password, $this)) {
return;
return 'The provided password is incorrect.';
}
$execution = $this->backup->executions()->where('id', $executionId)->first();
@@ -96,7 +96,11 @@ class BackupExecutions extends Component
$this->refreshBackupExecutions();
} catch (\Exception $e) {
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
return true;
}
return true;
}
public function download_file($exeuctionId)
@@ -36,6 +36,8 @@ class General extends Component
public ?int $publicPort = null;
public ?int $publicPortTimeout = 3600;
public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
@@ -80,6 +82,7 @@ class General extends Component
'portsMappings' => 'nullable|string',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
@@ -99,6 +102,8 @@ class General extends Component
'image.required' => 'The Docker Image field is required.',
'image.string' => 'The Docker Image must be a string.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
]
);
}
@@ -115,6 +120,7 @@ class General extends Component
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->public_port_timeout = $this->publicPortTimeout;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->save();
@@ -130,6 +136,7 @@ class General extends Component
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->dbUrl = $this->database->internal_db_url;
@@ -36,6 +36,8 @@ class General extends Component
public ?int $publicPort = null;
public ?int $publicPortTimeout = 3600;
public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
@@ -91,6 +93,7 @@ class General extends Component
'portsMappings' => 'nullable|string',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
@@ -109,6 +112,8 @@ class General extends Component
'image.required' => 'The Docker Image field is required.',
'image.string' => 'The Docker Image must be a string.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
]
);
}
@@ -124,6 +129,7 @@ class General extends Component
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->public_port_timeout = $this->publicPortTimeout;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl;
@@ -139,6 +145,7 @@ class General extends Component
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->enable_ssl = $this->database->enable_ssl;
+5 -1
View File
@@ -69,7 +69,11 @@ class Heading extends Component
public function mount()
{
$this->parameters = get_route_parameters();
$this->parameters = [
'project_uuid' => $this->database->environment->project->uuid,
'environment_uuid' => $this->database->environment->uuid,
'database_uuid' => $this->database->uuid,
];
}
public function stop()
+49 -14
View File
@@ -5,10 +5,12 @@ namespace App\Livewire\Project\Database;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\Service;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
class Import extends Component
@@ -104,17 +106,22 @@ class Import extends Component
public bool $unsupported = false;
// Store IDs instead of models for proper Livewire serialization
#[Locked]
public ?int $resourceId = null;
#[Locked]
public ?string $resourceType = null;
#[Locked]
public ?int $serverId = null;
// View-friendly properties to avoid computed property access in Blade
#[Locked]
public string $resourceUuid = '';
public string $resourceStatus = '';
#[Locked]
public string $resourceDbType = '';
public array $parameters = [];
@@ -135,6 +142,7 @@ class Import extends Component
public bool $error = false;
#[Locked]
public string $container;
public array $importCommands = [];
@@ -181,7 +189,7 @@ class Import extends Component
return null;
}
return Server::find($this->serverId);
return Server::ownedByCurrentTeam()->find($this->serverId);
}
public function getListeners()
@@ -401,20 +409,30 @@ EOD;
}
}
public function runImport()
public function runImport(string $password = ''): bool|string
{
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$this->authorize('update', $this->resource);
if (! ValidationPatterns::isValidContainerName($this->container)) {
$this->dispatch('error', 'Invalid container name.');
return true;
}
if ($this->filename === '') {
$this->dispatch('error', 'Please select a file to import.');
return;
return true;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
return true;
}
try {
@@ -434,7 +452,7 @@ EOD;
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
return;
return true;
}
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
@@ -442,7 +460,7 @@ EOD;
} else {
$this->dispatch('error', 'The file does not exist or has been deleted.');
return;
return true;
}
// Copy the restore command to a script file
@@ -474,11 +492,15 @@ EOD;
$this->dispatch('databaserestore');
}
} catch (\Throwable $e) {
return handleError($e, $this);
handleError($e, $this);
return true;
} finally {
$this->filename = null;
$this->importCommands = [];
}
return true;
}
public function loadAvailableS3Storages()
@@ -577,26 +599,36 @@ EOD;
}
}
public function restoreFromS3()
public function restoreFromS3(string $password = ''): bool|string
{
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$this->authorize('update', $this->resource);
if (! ValidationPatterns::isValidContainerName($this->container)) {
$this->dispatch('error', 'Invalid container name.');
return true;
}
if (! $this->s3StorageId || blank($this->s3Path)) {
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
return;
return true;
}
if (is_null($this->s3FileSize)) {
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
return;
return true;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
return true;
}
try {
@@ -613,7 +645,7 @@ EOD;
if (! $this->validateBucketName($bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return;
return true;
}
// Clean the S3 path
@@ -623,7 +655,7 @@ EOD;
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
return true;
}
// Get helper image
@@ -711,9 +743,12 @@ EOD;
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
} catch (\Throwable $e) {
$this->importRunning = false;
handleError($e, $this);
return handleError($e, $this);
return true;
}
return true;
}
public function buildRestoreCommand(string $tmpPath): string
@@ -38,6 +38,8 @@ class General extends Component
public ?int $publicPort = null;
public ?int $publicPortTimeout = 3600;
public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
@@ -94,6 +96,7 @@ class General extends Component
'portsMappings' => 'nullable|string',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
@@ -114,6 +117,8 @@ class General extends Component
'image.required' => 'The Docker Image field is required.',
'image.string' => 'The Docker Image must be a string.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
]
);
}
@@ -130,6 +135,7 @@ class General extends Component
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->public_port_timeout = $this->publicPortTimeout;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl;
@@ -146,6 +152,7 @@ class General extends Component
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->enable_ssl = $this->database->enable_ssl;
@@ -44,6 +44,8 @@ class General extends Component
public ?int $publicPort = null;
public ?int $publicPortTimeout = 3600;
public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null;
@@ -79,6 +81,7 @@ class General extends Component
'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
@@ -97,6 +100,8 @@ class General extends Component
'mariadbDatabase.required' => 'The MariaDB Database field is required.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
]
);
}
@@ -113,6 +118,7 @@ class General extends Component
'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public',
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Options',
'enableSsl' => 'Enable SSL',
];
@@ -154,6 +160,7 @@ class General extends Component
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->public_port_timeout = $this->publicPortTimeout;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
@@ -173,6 +180,7 @@ class General extends Component
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
@@ -42,6 +42,8 @@ class General extends Component
public ?int $publicPort = null;
public ?int $publicPortTimeout = 3600;
public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null;
@@ -78,6 +80,7 @@ class General extends Component
'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
@@ -96,6 +99,8 @@ class General extends Component
'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.',
]
);
@@ -112,6 +117,7 @@ class General extends Component
'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public',
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
@@ -153,6 +159,7 @@ class General extends Component
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->public_port_timeout = $this->publicPortTimeout;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
@@ -172,6 +179,7 @@ class General extends Component
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
@@ -44,6 +44,8 @@ class General extends Component
public ?int $publicPort = null;
public ?int $publicPortTimeout = 3600;
public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null;
@@ -81,6 +83,7 @@ class General extends Component
'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
@@ -100,6 +103,8 @@ class General extends Component
'mysqlDatabase.required' => 'The MySQL Database field is required.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.',
]
);
@@ -117,6 +122,7 @@ class General extends Component
'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public',
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
@@ -159,6 +165,7 @@ class General extends Component
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->public_port_timeout = $this->publicPortTimeout;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
@@ -179,6 +186,7 @@ class General extends Component
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
@@ -48,6 +48,8 @@ class General extends Component
public ?int $publicPort = null;
public ?int $publicPortTimeout = 3600;
public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null;
@@ -93,6 +95,7 @@ class General extends Component
'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
@@ -111,6 +114,8 @@ class General extends Component
'postgresDb.required' => 'The Postgres Database field is required.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.',
]
);
@@ -130,6 +135,7 @@ class General extends Component
'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public',
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
@@ -174,6 +180,7 @@ class General extends Component
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->public_port_timeout = $this->publicPortTimeout;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
@@ -196,6 +203,7 @@ class General extends Component
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
@@ -36,6 +36,8 @@ class General extends Component
public ?int $publicPort = null;
public ?int $publicPortTimeout = 3600;
public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null;
@@ -74,6 +76,7 @@ class General extends Component
'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'redisUsername' => 'required',
@@ -90,6 +93,8 @@ class General extends Component
'name.required' => 'The Name field is required.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'redisUsername.required' => 'The Redis Username field is required.',
'redisPassword.required' => 'The Redis Password field is required.',
]
@@ -104,6 +109,7 @@ class General extends Component
'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public',
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Options',
'redisUsername' => 'Redis Username',
'redisPassword' => 'Redis Password',
@@ -143,6 +149,7 @@ class General extends Component
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->public_port_timeout = $this->publicPortTimeout;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
@@ -158,6 +165,7 @@ class General extends Component
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
+2 -2
View File
@@ -21,7 +21,7 @@ class DeleteProject extends Component
public function mount()
{
$this->parameters = get_route_parameters();
$this->projectName = Project::findOrFail($this->project_id)->name;
$this->projectName = Project::ownedByCurrentTeam()->findOrFail($this->project_id)->name;
}
public function delete()
@@ -29,7 +29,7 @@ class DeleteProject extends Component
$this->validate([
'project_id' => 'required|int',
]);
$project = Project::findOrFail($this->project_id);
$project = Project::ownedByCurrentTeam()->findOrFail($this->project_id);
$this->authorize('delete', $project);
if ($project->isEmpty()) {
+10 -4
View File
@@ -41,8 +41,8 @@ class DockerCompose extends Component
// Validate for command injection BEFORE saving to database
validateDockerComposeForInjection($this->dockerComposeRaw);
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$destination_uuid = $this->query['destination'];
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
@@ -63,10 +63,16 @@ class DockerCompose extends Component
]);
$variables = parseEnvFormatToArray($this->envFile);
foreach ($variables as $key => $variable) {
foreach ($variables as $key => $data) {
// Extract value and comment from parsed data
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
$value = is_array($data) ? ($data['value'] ?? '') : $data;
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
EnvironmentVariable::create([
'key' => $key,
'value' => $variable,
'value' => $value,
'comment' => $comment,
'is_preview' => false,
'resourceable_id' => $service->id,
'resourceable_type' => $service->getMorphClass(),
+2 -2
View File
@@ -121,8 +121,8 @@ class DockerImage extends Component
}
$destination_class = $destination->getMorphClass();
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
// Append @sha256 to image name if using digest and not already present
$imageName = $parser->getFullImageNameWithoutTag();
@@ -75,6 +75,11 @@ class GithubPrivateRepository extends Component
$this->github_apps = GithubApp::private();
}
public function updatedSelectedRepositoryId(): void
{
$this->loadBranches();
}
public function updatedBuildPack()
{
if ($this->build_pack === 'nixpacks') {
@@ -158,10 +163,12 @@ class GithubPrivateRepository extends Component
'selected_repository_owner' => $this->selected_repository_owner,
'selected_repository_repo' => $this->selected_repository_repo,
'selected_branch_name' => $this->selected_branch_name,
'docker_compose_location' => $this->docker_compose_location,
], [
'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/',
'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/',
'selected_branch_name' => ['required', 'string', new ValidGitBranch],
'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
]);
if ($validator->fails()) {
@@ -178,8 +185,8 @@ class GithubPrivateRepository extends Component
}
$destination_class = $destination->getMorphClass();
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$application = Application::create([
'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name),
@@ -57,15 +57,6 @@ class GithubPrivateRepositoryDeployKey extends Component
private ?string $git_repository = null;
protected $rules = [
'repository_url' => ['required', 'string'],
'branch' => ['required', 'string'],
'port' => 'required|numeric',
'is_static' => 'required|boolean',
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
];
protected function rules()
{
return [
@@ -75,6 +66,7 @@ class GithubPrivateRepositoryDeployKey extends Component
'is_static' => 'required|boolean',
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
];
}
@@ -152,8 +144,8 @@ class GithubPrivateRepositoryDeployKey extends Component
// Note: git_repository has already been validated and transformed in get_git_source()
// It may now be in SSH format (git@host:repo.git) which is valid for deploy keys
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
if ($this->git_source === 'other') {
$application_init = [
'name' => generate_random_name(),
Loaded 100 of 622 files, more files were not shown because too many files have changed in this diff. Show more