mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 16:14:35 +00:00
Merge remote-tracking branch 'origin/next' into audit-policies
# Conflicts: # app/Http/Controllers/Api/SecurityController.php # app/Http/Controllers/Api/ServersController.php # app/Livewire/Admin/Index.php # app/Livewire/Destination/Show.php # app/Livewire/NavbarDeleteTeam.php # app/Livewire/Project/Application/Previews.php # app/Livewire/Project/DeleteProject.php # app/Livewire/Project/Shared/ResourceOperations.php # app/Livewire/Server/Resources.php # app/Livewire/Server/ValidateAndInstall.php # app/Livewire/Storage/Show.php # resources/views/livewire/dashboard.blade.php # resources/views/livewire/project/application/heading.blade.php # resources/views/livewire/project/database/heading.blade.php # resources/views/livewire/project/service/heading.blade.php # resources/views/livewire/project/shared/environment-variable/show.blade.php # resources/views/livewire/project/shared/scheduled-task/show.blade.php # tests/Feature/Security/TrustHostsMiddlewareTest.php
This commit is contained in:
commit
43f33d94ad
685 files changed
+41904
-5674
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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,7 +21,7 @@ class ResetUserPassword implements ResetsUserPasswords
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
$user->fill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
$user->deleteAllSessions();
|
||||
|
||||
@@ -24,7 +24,7 @@ class UpdateUserPassword implements UpdatesUserPasswords
|
||||
'current_password.current_password' => __('The provided password does not match your current password.'),
|
||||
])->validateWithBag('updatePassword');
|
||||
|
||||
$user->forceFill([
|
||||
$user->fill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
) {
|
||||
$this->updateVerifiedUser($user, $input);
|
||||
} else {
|
||||
$user->forceFill([
|
||||
$user->fill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
])->save();
|
||||
@@ -49,7 +49,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
*/
|
||||
protected function updateVerifiedUser(User $user, array $input): void
|
||||
{
|
||||
$user->forceFill([
|
||||
$user->fill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'email_verified_at' => null,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>.');
|
||||
@@ -118,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 && '.
|
||||
@@ -131,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 && '.
|
||||
@@ -141,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 && '.
|
||||
@@ -152,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';
|
||||
@@ -163,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';
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class RefundSubscription
|
||||
/**
|
||||
* Check if the team's subscription is eligible for a refund.
|
||||
*
|
||||
* @return array{eligible: bool, days_remaining: int, reason: string}
|
||||
* @return array{eligible: bool, days_remaining: int, reason: string, current_period_end: int|null}
|
||||
*/
|
||||
public function checkEligibility(Team $team): array
|
||||
{
|
||||
@@ -43,8 +43,10 @@ class RefundSubscription
|
||||
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}'.");
|
||||
return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd);
|
||||
}
|
||||
|
||||
$startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date);
|
||||
@@ -52,13 +54,14 @@ class RefundSubscription
|
||||
$daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart;
|
||||
|
||||
if ($daysRemaining <= 0) {
|
||||
return $this->ineligible('The 30-day refund window has expired.');
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -99,16 +102,27 @@ class RefundSubscription
|
||||
'payment_intent' => $paymentIntentId,
|
||||
]);
|
||||
|
||||
$this->stripe->subscriptions->cancel($subscription->stripe_subscription_id);
|
||||
// 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,
|
||||
'stripe_feedback' => 'Refund requested by user',
|
||||
'stripe_comment' => 'Full refund processed within 30-day window at '.now()->toDateTimeString(),
|
||||
'stripe_refunded_at' => now(),
|
||||
]);
|
||||
|
||||
$team->subscriptionEnded();
|
||||
@@ -128,14 +142,15 @@ class RefundSubscription
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{eligible: bool, days_remaining: int, reason: string}
|
||||
* @return array{eligible: bool, days_remaining: int, reason: string, current_period_end: int|null}
|
||||
*/
|
||||
private function ineligible(string $reason): array
|
||||
private function ineligible(string $reason, ?int $currentPeriodEnd = null): array
|
||||
{
|
||||
return [
|
||||
'eligible' => false,
|
||||
'days_remaining' => 0,
|
||||
'reason' => $reason,
|
||||
'current_period_end' => $currentPeriodEnd,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Actions\Stripe;
|
||||
|
||||
use App\Jobs\ServerLimitCheckJob;
|
||||
use App\Models\Team;
|
||||
use Stripe\Exception\InvalidRequestException;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class UpdateSubscriptionQuantity
|
||||
@@ -42,6 +43,7 @@ class UpdateSubscriptionQuantity
|
||||
}
|
||||
|
||||
$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([
|
||||
@@ -99,6 +101,7 @@ class UpdateSubscriptionQuantity
|
||||
'tax_description' => $taxDescription,
|
||||
'quantity' => $quantity,
|
||||
'currency' => $currency,
|
||||
'billing_interval' => $billingInterval,
|
||||
],
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
@@ -153,12 +156,19 @@ class UpdateSubscriptionQuantity
|
||||
\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
|
||||
$this->stripe->subscriptions->update($subscription->stripe_subscription_id, [
|
||||
'items' => [
|
||||
['id' => $item->id, 'quantity' => $previousQuantity],
|
||||
],
|
||||
'proration_behavior' => 'none',
|
||||
]);
|
||||
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) {
|
||||
@@ -177,7 +187,7 @@ class UpdateSubscriptionQuantity
|
||||
\Log::info("Subscription {$subscription->stripe_subscription_id} quantity updated to {$quantity} for team {$team->name}");
|
||||
|
||||
return ['success' => true, 'error' => null];
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
} catch (InvalidRequestException $e) {
|
||||
\Log::error("Stripe update quantity error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -88,6 +88,14 @@ class Services extends Command
|
||||
$payload['envs'] = base64_encode($envFileContent);
|
||||
}
|
||||
|
||||
if (str($data->get('amd_only'))->toBoolean()) {
|
||||
$payload['amd_only'] = true;
|
||||
}
|
||||
|
||||
if (str($data->get('arm_only'))->toBoolean()) {
|
||||
$payload['arm_only'] = true;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
@@ -160,6 +168,14 @@ class Services extends Command
|
||||
$payload['envs'] = base64_encode($modifiedEnvContent);
|
||||
}
|
||||
|
||||
if (str($data->get('amd_only'))->toBoolean()) {
|
||||
$payload['amd_only'] = true;
|
||||
}
|
||||
|
||||
if (str($data->get('arm_only'))->toBoolean()) {
|
||||
$payload['arm_only'] = true;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
@@ -229,6 +245,14 @@ class Services extends Command
|
||||
$payload['envs'] = $modifiedEnvContent;
|
||||
}
|
||||
|
||||
if (str($data->get('amd_only'))->toBoolean()) {
|
||||
$payload['amd_only'] = true;
|
||||
}
|
||||
|
||||
if (str($data->get('arm_only'))->toBoolean()) {
|
||||
$payload['arm_only'] = true;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
@@ -209,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
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -4,12 +4,15 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Database\StartDatabase;
|
||||
use App\Actions\Service\StartService;
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
@@ -128,7 +131,7 @@ class DeployController extends Controller
|
||||
return response()->json(['message' => 'Deployment not found.'], 404);
|
||||
}
|
||||
$application = $deployment->application;
|
||||
if (! $application || data_get($application->team(), 'id') !== $teamId) {
|
||||
if (! $application || data_get($application->team(), 'id') !== (int) $teamId) {
|
||||
return response()->json(['message' => 'Deployment not found.'], 404);
|
||||
}
|
||||
|
||||
@@ -228,8 +231,8 @@ class DeployController extends Controller
|
||||
|
||||
// Check if deployment can be cancelled (must be queued or in_progress)
|
||||
$cancellableStatuses = [
|
||||
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
|
||||
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
ApplicationDeploymentStatus::QUEUED->value,
|
||||
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
];
|
||||
|
||||
if (! in_array($deployment->status, $cancellableStatuses)) {
|
||||
@@ -246,11 +249,11 @@ class DeployController extends Controller
|
||||
|
||||
// Mark deployment as cancelled
|
||||
$deployment->update([
|
||||
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
|
||||
// Get the server
|
||||
$server = Server::find($build_server_id);
|
||||
$server = Server::whereTeamId($teamId)->find($build_server_id);
|
||||
|
||||
if ($server) {
|
||||
// Add cancellation log entry
|
||||
@@ -304,6 +307,8 @@ class DeployController extends Controller
|
||||
new OA\Parameter(name: 'uuid', in: 'query', description: 'Resource UUID(s). Comma separated list is also accepted.', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'force', in: 'query', description: 'Force rebuild (without cache)', schema: new OA\Schema(type: 'boolean')),
|
||||
new OA\Parameter(name: 'pr', in: 'query', description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.', schema: new OA\Schema(type: 'integer')),
|
||||
new OA\Parameter(name: 'pull_request_id', in: 'query', description: 'Preview deployment identifier. Alias of pr.', schema: new OA\Schema(type: 'integer')),
|
||||
new OA\Parameter(name: 'docker_tag', in: 'query', description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
|
||||
responses: [
|
||||
@@ -354,7 +359,9 @@ class DeployController extends Controller
|
||||
$uuids = $request->input('uuid');
|
||||
$tags = $request->input('tag');
|
||||
$force = $request->input('force') ?? false;
|
||||
$pr = $request->input('pr') ? max((int) $request->input('pr'), 0) : 0;
|
||||
$pullRequestId = $request->input('pull_request_id', $request->input('pr'));
|
||||
$pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0;
|
||||
$dockerTag = $request->string('docker_tag')->trim()->value() ?: null;
|
||||
|
||||
if ($uuids && $tags) {
|
||||
return response()->json(['message' => 'You can only use uuid or tag, not both.'], 400);
|
||||
@@ -362,16 +369,22 @@ class DeployController extends Controller
|
||||
if ($tags && $pr) {
|
||||
return response()->json(['message' => 'You can only use tag or pr, not both.'], 400);
|
||||
}
|
||||
if ($dockerTag && $pr === 0) {
|
||||
return response()->json(['message' => 'docker_tag requires pull_request_id.'], 400);
|
||||
}
|
||||
if ($dockerTag && $tags) {
|
||||
return response()->json(['message' => 'You can only use tag or docker_tag, not both.'], 400);
|
||||
}
|
||||
if ($tags) {
|
||||
return $this->by_tags($tags, $teamId, $force);
|
||||
} elseif ($uuids) {
|
||||
return $this->by_uuids($uuids, $teamId, $force, $pr);
|
||||
return $this->by_uuids($uuids, $teamId, $force, $pr, $dockerTag);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'You must provide uuid or tag.'], 400);
|
||||
}
|
||||
|
||||
private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0)
|
||||
private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0, ?string $dockerTag = null)
|
||||
{
|
||||
$uuids = explode(',', $uuid);
|
||||
$uuids = collect(array_filter($uuids));
|
||||
@@ -384,15 +397,22 @@ class DeployController extends Controller
|
||||
foreach ($uuids as $uuid) {
|
||||
$resource = getResourceByUuid($uuid, $teamId);
|
||||
if ($resource) {
|
||||
$dockerTagForResource = $dockerTag;
|
||||
if ($pr !== 0) {
|
||||
$preview = $resource->previews()->where('pull_request_id', $pr)->first();
|
||||
$preview = null;
|
||||
if ($resource instanceof Application && $resource->build_pack === 'dockerimage') {
|
||||
$preview = $this->upsertDockerImagePreview($resource, $pr, $dockerTag);
|
||||
$dockerTagForResource = $preview?->docker_registry_image_tag;
|
||||
} else {
|
||||
$preview = $resource->previews()->where('pull_request_id', $pr)->first();
|
||||
}
|
||||
if (! $preview) {
|
||||
$deployments->push(['message' => "Pull request {$pr} not found for this resource.", 'resource_uuid' => $uuid]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$result = $this->deploy_resource($resource, $force, $pr);
|
||||
$result = $this->deploy_resource($resource, $force, $pr, $dockerTagForResource);
|
||||
if (isset($result['status']) && $result['status'] === 429) {
|
||||
return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
|
||||
}
|
||||
@@ -465,7 +485,7 @@ class DeployController extends Controller
|
||||
return response()->json(['message' => 'No resources found with this tag.'], 404);
|
||||
}
|
||||
|
||||
public function deploy_resource($resource, bool $force = false, int $pr = 0): array
|
||||
public function deploy_resource($resource, bool $force = false, int $pr = 0, ?string $dockerTag = null): array
|
||||
{
|
||||
$message = null;
|
||||
$deployment_uuid = null;
|
||||
@@ -477,9 +497,12 @@ class DeployController extends Controller
|
||||
// Check authorization for application deployment
|
||||
try {
|
||||
$this->authorize('deploy', $resource);
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
} catch (AuthorizationException $e) {
|
||||
return ['message' => 'Unauthorized to deploy this application.', 'deployment_uuid' => null];
|
||||
}
|
||||
if ($dockerTag !== null && $resource->build_pack !== 'dockerimage') {
|
||||
return ['message' => 'docker_tag can only be used with Docker Image applications.', 'deployment_uuid' => null];
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$result = queue_application_deployment(
|
||||
application: $resource,
|
||||
@@ -487,6 +510,7 @@ class DeployController extends Controller
|
||||
force_rebuild: $force,
|
||||
pull_request_id: $pr,
|
||||
is_api: true,
|
||||
docker_registry_image_tag: $dockerTag,
|
||||
);
|
||||
if ($result['status'] === 'queue_full') {
|
||||
return ['message' => $result['message'], 'deployment_uuid' => null, 'status' => 429];
|
||||
@@ -500,7 +524,7 @@ class DeployController extends Controller
|
||||
// Check authorization for service deployment
|
||||
try {
|
||||
$this->authorize('deploy', $resource);
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
} catch (AuthorizationException $e) {
|
||||
return ['message' => 'Unauthorized to deploy this service.', 'deployment_uuid' => null];
|
||||
}
|
||||
StartService::run($resource);
|
||||
@@ -510,7 +534,7 @@ class DeployController extends Controller
|
||||
// Database resource - check authorization
|
||||
try {
|
||||
$this->authorize('manage', $resource);
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
} catch (AuthorizationException $e) {
|
||||
return ['message' => 'Unauthorized to start this database.', 'deployment_uuid' => null];
|
||||
}
|
||||
StartDatabase::dispatch($resource);
|
||||
@@ -525,6 +549,34 @@ class DeployController extends Controller
|
||||
return ['message' => $message, 'deployment_uuid' => $deployment_uuid];
|
||||
}
|
||||
|
||||
private function upsertDockerImagePreview(Application $application, int $pullRequestId, ?string $dockerTag): ?ApplicationPreview
|
||||
{
|
||||
$preview = $application->previews()->where('pull_request_id', $pullRequestId)->first();
|
||||
|
||||
if (! $preview && $dockerTag === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $preview) {
|
||||
$preview = ApplicationPreview::create([
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pullRequestId,
|
||||
'pull_request_html_url' => '',
|
||||
'docker_registry_image_tag' => $dockerTag,
|
||||
]);
|
||||
$preview->generate_preview_fqdn();
|
||||
|
||||
return $preview;
|
||||
}
|
||||
|
||||
if ($dockerTag !== null && $preview->docker_registry_image_tag !== $dockerTag) {
|
||||
$preview->docker_registry_image_tag = $dockerTag;
|
||||
$preview->save();
|
||||
}
|
||||
|
||||
return $preview;
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List application deployments',
|
||||
description: 'List application deployments by using the app uuid',
|
||||
|
||||
@@ -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;
|
||||
@@ -182,7 +185,7 @@ class GithubController extends Controller
|
||||
}
|
||||
$this->authorize('create', [GithubApp::class]);
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -205,8 +208,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',
|
||||
@@ -371,7 +374,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);
|
||||
@@ -473,7 +476,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);
|
||||
@@ -589,10 +592,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';
|
||||
@@ -653,7 +656,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);
|
||||
@@ -739,7 +742,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);
|
||||
|
||||
@@ -587,7 +587,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -147,11 +147,15 @@ class OtherController extends Controller
|
||||
|
||||
public function feedback(Request $request)
|
||||
{
|
||||
$content = $request->input('content');
|
||||
$data = $request->validate([
|
||||
'content' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
]);
|
||||
|
||||
$webhook_url = config('constants.webhooks.feedback_discord_webhook');
|
||||
if ($webhook_url) {
|
||||
Http::post($webhook_url, [
|
||||
'content' => $content,
|
||||
Http::timeout(5)->post($webhook_url, [
|
||||
'content' => $data['content'],
|
||||
'allowed_mentions' => ['parse' => []],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Project;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use OpenApi\Attributes as OA;
|
||||
@@ -236,7 +237,7 @@ class ProjectController extends Controller
|
||||
$this->authorize('create', [Project::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = Validator::make($request->all(), [
|
||||
@@ -349,7 +350,7 @@ class ProjectController extends Controller
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = Validator::make($request->all(), [
|
||||
@@ -604,7 +605,7 @@ class ProjectController extends Controller
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = Validator::make($request->all(), [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -178,7 +179,7 @@ class SecurityController extends Controller
|
||||
}
|
||||
$this->authorize('create', [PrivateKey::class]);
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
@@ -302,7 +303,7 @@ class SecurityController extends Controller
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -333,7 +334,7 @@ class SecurityController extends Controller
|
||||
], 404);
|
||||
}
|
||||
$this->authorize('update', $foundKey);
|
||||
$foundKey->update($request->all());
|
||||
$foundKey->update($request->only($allowedFields));
|
||||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => $foundKey->uuid,
|
||||
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -290,7 +291,11 @@ 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) {
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
|
||||
if (! $application) {
|
||||
@@ -301,7 +306,9 @@ class ServersController extends Controller
|
||||
}
|
||||
$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) {
|
||||
@@ -341,7 +348,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;
|
||||
@@ -354,7 +363,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,
|
||||
@@ -370,13 +380,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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -590,6 +600,11 @@ class ServersController extends Controller
|
||||
'is_build_server' => ['type' => 'boolean', 'description' => 'Is build server.'],
|
||||
'instant_validate' => ['type' => 'boolean', 'description' => 'Instant validate.'],
|
||||
'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type.'],
|
||||
'concurrent_builds' => ['type' => 'integer', 'description' => 'Number of concurrent builds.'],
|
||||
'dynamic_timeout' => ['type' => 'integer', 'description' => 'Deployment timeout in seconds.'],
|
||||
'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'],
|
||||
'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'],
|
||||
'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -626,7 +641,7 @@ class ServersController extends Controller
|
||||
)]
|
||||
public function update_server(Request $request)
|
||||
{
|
||||
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type'];
|
||||
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -647,6 +662,11 @@ class ServersController extends Controller
|
||||
'is_build_server' => 'boolean|nullable',
|
||||
'instant_validate' => 'boolean|nullable',
|
||||
'proxy_type' => 'string|nullable',
|
||||
'concurrent_builds' => 'integer|min:1',
|
||||
'dynamic_timeout' => 'integer|min:1',
|
||||
'deployment_queue_limit' => 'integer|min:1',
|
||||
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
|
||||
'server_disk_usage_check_frequency' => 'string',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -684,6 +704,19 @@ class ServersController extends Controller
|
||||
'is_build_server' => $request->is_build_server,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['server_disk_usage_check_frequency' => ['Invalid Cron / Human expression for Disk Usage Check Frequency.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']);
|
||||
if (! empty($advancedSettings)) {
|
||||
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
|
||||
}
|
||||
|
||||
if ($request->instant_validate) {
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
@@ -762,12 +795,22 @@ class ServersController extends Controller
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $server);
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
@@ -221,11 +225,12 @@ class ServicesController extends Controller
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
|
||||
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'],
|
||||
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").'],
|
||||
],
|
||||
),
|
||||
],
|
||||
'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.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -292,7 +297,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)) {
|
||||
@@ -302,7 +307,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 = [
|
||||
@@ -321,6 +326,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.',
|
||||
@@ -433,6 +439,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) {
|
||||
@@ -489,7 +498,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',
|
||||
@@ -507,6 +516,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.',
|
||||
@@ -613,6 +623,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);
|
||||
@@ -834,11 +847,12 @@ class ServicesController extends Controller
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
|
||||
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'],
|
||||
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").'],
|
||||
],
|
||||
),
|
||||
],
|
||||
'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.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
@@ -916,7 +930,7 @@ class ServicesController extends Controller
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -927,7 +941,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',
|
||||
@@ -940,6 +954,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.',
|
||||
@@ -1005,6 +1020,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();
|
||||
@@ -1197,7 +1215,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);
|
||||
}
|
||||
@@ -1332,7 +1350,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);
|
||||
}
|
||||
@@ -1352,6 +1370,7 @@ class ServicesController extends Controller
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
'comment' => 'string|nullable|max:256',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@@ -1451,7 +1470,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);
|
||||
}
|
||||
@@ -1560,14 +1579,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();
|
||||
@@ -1680,6 +1699,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(
|
||||
@@ -1731,7 +1759,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(
|
||||
[
|
||||
@@ -1827,4 +1857,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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -57,10 +57,29 @@ class Bitbucket extends Controller
|
||||
}
|
||||
foreach ($applications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket');
|
||||
if (empty($webhook_secret)) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Webhook secret not configured.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$payload = $request->getContent();
|
||||
|
||||
[$algo, $hash] = explode('=', $x_bitbucket_token, 2);
|
||||
$payloadHash = hash_hmac($algo, $payload, $webhook_secret);
|
||||
$parts = explode('=', $x_bitbucket_token, 2);
|
||||
if (count($parts) !== 2 || $parts[0] !== 'sha256') {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Invalid signature.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$hash = $parts[1];
|
||||
$payloadHash = hash_hmac('sha256', $payload, $webhook_secret);
|
||||
if (! hash_equals($hash, $payloadHash) && ! isDev()) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
|
||||
@@ -67,6 +67,15 @@ class Gitea extends Controller
|
||||
}
|
||||
foreach ($applications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_gitea');
|
||||
if (empty($webhook_secret)) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Webhook secret not configured.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
|
||||
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
|
||||
$return_payloads->push([
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -78,6 +81,15 @@ class Github extends Controller
|
||||
foreach ($applicationsByServer as $serverId => $serverApplications) {
|
||||
foreach ($serverApplications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
|
||||
if (empty($webhook_secret)) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Webhook secret not configured.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
|
||||
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
|
||||
$return_payloads->push([
|
||||
@@ -246,6 +258,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.');
|
||||
}
|
||||
|
||||
@@ -100,7 +100,16 @@ class Gitlab extends Controller
|
||||
}
|
||||
foreach ($applications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
|
||||
if (! hash_equals($webhook_secret ?? '', $x_gitlab_token ?? '')) {
|
||||
if (empty($webhook_secret)) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Webhook secret not configured.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
|
||||
@@ -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;
|
||||
@@ -75,6 +76,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private ?string $dockerImageTag = null;
|
||||
|
||||
private ?string $dockerImagePreviewTag = null;
|
||||
|
||||
private GithubApp|GitlabApp|string $source = 'other';
|
||||
|
||||
private StandaloneDocker|SwarmDocker $destination;
|
||||
@@ -207,6 +210,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->restart_only = $this->application_deployment_queue->restart_only;
|
||||
$this->restart_only = $this->restart_only && $this->application->build_pack !== 'dockerimage' && $this->application->build_pack !== 'dockerfile';
|
||||
$this->only_this_server = $this->application_deployment_queue->only_this_server;
|
||||
$this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag;
|
||||
|
||||
$this->git_type = data_get($this->application_deployment_queue, 'git_type');
|
||||
|
||||
@@ -223,7 +227,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;
|
||||
|
||||
@@ -241,6 +249,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
// Set preview fqdn
|
||||
if ($this->pull_request_id !== 0) {
|
||||
$this->preview = ApplicationPreview::findPreviewByApplicationAndPullId($this->application->id, $this->pull_request_id);
|
||||
if ($this->application->build_pack === 'dockerimage' && str($this->dockerImagePreviewTag)->isEmpty()) {
|
||||
$this->dockerImagePreviewTag = $this->preview?->docker_registry_image_tag;
|
||||
}
|
||||
if ($this->preview) {
|
||||
if ($this->application->build_pack === 'dockercompose') {
|
||||
$this->preview->generate_preview_fqdn_compose();
|
||||
@@ -283,7 +294,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);
|
||||
@@ -312,7 +324,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
|
||||
@@ -443,7 +459,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$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()}");
|
||||
@@ -456,14 +472,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->just_restart();
|
||||
|
||||
return;
|
||||
} elseif ($this->application->build_pack === 'dockerimage') {
|
||||
$this->deploy_dockerimage_buildpack();
|
||||
} elseif ($this->pull_request_id !== 0) {
|
||||
$this->deploy_pull_request();
|
||||
} elseif ($this->application->dockerfile) {
|
||||
$this->deploy_simple_dockerfile();
|
||||
} elseif ($this->application->build_pack === 'dockercompose') {
|
||||
$this->deploy_docker_compose_buildpack();
|
||||
} elseif ($this->application->build_pack === 'dockerimage') {
|
||||
$this->deploy_dockerimage_buildpack();
|
||||
} elseif ($this->application->build_pack === 'dockerfile') {
|
||||
$this->deploy_dockerfile_buildpack();
|
||||
} elseif ($this->application->build_pack === 'static') {
|
||||
@@ -483,7 +499,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());
|
||||
}
|
||||
|
||||
@@ -491,7 +507,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());
|
||||
}
|
||||
}
|
||||
@@ -499,13 +515,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());
|
||||
}
|
||||
}
|
||||
@@ -544,11 +560,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
private function deploy_dockerimage_buildpack()
|
||||
{
|
||||
$this->dockerImage = $this->application->docker_registry_image_name;
|
||||
if (str($this->application->docker_registry_image_tag)->isEmpty()) {
|
||||
$this->dockerImageTag = 'latest';
|
||||
} else {
|
||||
$this->dockerImageTag = $this->application->docker_registry_image_tag;
|
||||
}
|
||||
$this->dockerImageTag = $this->resolveDockerImageTag();
|
||||
|
||||
// Check if this is an image hash deployment
|
||||
$isImageHash = str($this->dockerImageTag)->startsWith('sha256-');
|
||||
@@ -565,18 +577,34 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->rolling_update();
|
||||
}
|
||||
|
||||
private function resolveDockerImageTag(): string
|
||||
{
|
||||
if ($this->pull_request_id !== 0 && str($this->dockerImagePreviewTag)->isNotEmpty()) {
|
||||
return $this->dockerImagePreviewTag;
|
||||
}
|
||||
|
||||
if (str($this->application->docker_registry_image_tag)->isNotEmpty()) {
|
||||
return $this->application->docker_registry_image_tag;
|
||||
}
|
||||
|
||||
return 'latest';
|
||||
}
|
||||
|
||||
private function deploy_docker_compose_buildpack()
|
||||
{
|
||||
if (data_get($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();
|
||||
@@ -684,7 +712,7 @@ 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(' ');
|
||||
|
||||
// Inject build args right after 'build' subcommand (not at the end)
|
||||
@@ -722,7 +750,7 @@ 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(' ');
|
||||
$command .= " {$build_args_string}";
|
||||
$this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.');
|
||||
@@ -772,7 +800,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
try {
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true],
|
||||
[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')) {
|
||||
@@ -790,7 +818,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 {
|
||||
@@ -807,11 +835,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->write_deployment_configurations();
|
||||
if ($this->preserveRepository) {
|
||||
$this->execute_remote_command(
|
||||
['command' => "cd {$server_workdir} && {$start_command}", 'hidden' => true],
|
||||
['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' => true],
|
||||
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -823,14 +851,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();
|
||||
}
|
||||
@@ -1102,10 +1130,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();
|
||||
}
|
||||
@@ -1243,7 +1282,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
});
|
||||
|
||||
foreach ($runtime_environment_variables as $env) {
|
||||
$envs->push($env->key.'='.$env->real_value);
|
||||
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
|
||||
}
|
||||
|
||||
// Check for PORT environment variable mismatch with ports_exposes
|
||||
@@ -1309,8 +1348,24 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
});
|
||||
|
||||
foreach ($runtime_environment_variables_preview as $env) {
|
||||
$envs->push($env->key.'='.$env->real_value);
|
||||
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
|
||||
}
|
||||
|
||||
// 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->getResolvedValueWithServer($this->mainServer));
|
||||
}
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
@@ -1549,10 +1604,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
|
||||
foreach ($sorted_environment_variables as $env) {
|
||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||
// For literal/multiline vars, real_value includes quotes that we need to remove
|
||||
if ($env->is_literal || $env->is_multiline) {
|
||||
// Strip outer quotes from real_value and apply proper bash escaping
|
||||
$value = trim($env->real_value, "'");
|
||||
$value = trim($resolvedValue, "'");
|
||||
$escapedValue = escapeBashEnvValue($value);
|
||||
|
||||
if (isDev() && isset($envs_dict[$env->key])) {
|
||||
@@ -1564,13 +1620,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
if (isDev()) {
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline');
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$env->real_value}");
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$resolvedValue}");
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] stripped value: {$value}");
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
|
||||
}
|
||||
} else {
|
||||
// For normal vars, use double quotes to allow $VAR expansion
|
||||
$escapedValue = escapeBashDoubleQuoted($env->real_value);
|
||||
$escapedValue = escapeBashDoubleQuoted($resolvedValue);
|
||||
|
||||
if (isDev() && isset($envs_dict[$env->key])) {
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
|
||||
@@ -1581,7 +1637,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
if (isDev()) {
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)');
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$env->real_value}");
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$resolvedValue}");
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
|
||||
}
|
||||
}
|
||||
@@ -1600,10 +1656,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
|
||||
foreach ($sorted_environment_variables as $env) {
|
||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||
// For literal/multiline vars, real_value includes quotes that we need to remove
|
||||
if ($env->is_literal || $env->is_multiline) {
|
||||
// Strip outer quotes from real_value and apply proper bash escaping
|
||||
$value = trim($env->real_value, "'");
|
||||
$value = trim($resolvedValue, "'");
|
||||
$escapedValue = escapeBashEnvValue($value);
|
||||
|
||||
if (isDev() && isset($envs_dict[$env->key])) {
|
||||
@@ -1615,13 +1672,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
if (isDev()) {
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline');
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$env->real_value}");
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$resolvedValue}");
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] stripped value: {$value}");
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
|
||||
}
|
||||
} else {
|
||||
// For normal vars, use double quotes to allow $VAR expansion
|
||||
$escapedValue = escapeBashDoubleQuoted($env->real_value);
|
||||
$escapedValue = escapeBashDoubleQuoted($resolvedValue);
|
||||
|
||||
if (isDev() && isset($envs_dict[$env->key])) {
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
|
||||
@@ -1632,7 +1689,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
if (isDev()) {
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)');
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$env->real_value}");
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$resolvedValue}");
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
|
||||
}
|
||||
}
|
||||
@@ -1894,6 +1951,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private function deploy_pull_request()
|
||||
{
|
||||
if ($this->application->build_pack === 'dockerimage') {
|
||||
$this->deploy_dockerimage_buildpack();
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->application->build_pack === 'dockercompose') {
|
||||
$this->deploy_docker_compose_buildpack();
|
||||
|
||||
@@ -1976,9 +2038,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) {
|
||||
@@ -2090,7 +2154,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');
|
||||
@@ -2133,7 +2197,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',
|
||||
]
|
||||
@@ -2196,7 +2260,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',
|
||||
]
|
||||
@@ -2312,13 +2376,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}";
|
||||
|
||||
@@ -2330,14 +2394,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->env_nixpacks_args = collect([]);
|
||||
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}");
|
||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||
if (! is_null($resolvedValue) && $resolvedValue !== '') {
|
||||
$value = ($env->is_literal || $env->is_multiline) ? trim($resolvedValue, "'") : $resolvedValue;
|
||||
$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}");
|
||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||
if (! is_null($resolvedValue) && $resolvedValue !== '') {
|
||||
$value = ($env->is_literal || $env->is_multiline) ? trim($resolvedValue, "'") : $resolvedValue;
|
||||
$this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2347,7 +2415,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}"));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2462,7 +2530,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
|
||||
@@ -2473,8 +2543,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
->get();
|
||||
|
||||
foreach ($envs as $env) {
|
||||
if (! is_null($env->real_value)) {
|
||||
$this->env_args->put($env->key, $env->real_value);
|
||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||
if (! is_null($resolvedValue)) {
|
||||
$this->env_args->put($env->key, $resolvedValue);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -2484,8 +2555,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
->get();
|
||||
|
||||
foreach ($envs as $env) {
|
||||
if (! is_null($env->real_value)) {
|
||||
$this->env_args->put($env->key, $env->real_value);
|
||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||
if (! is_null($resolvedValue)) {
|
||||
$this->env_args->put($env->key, $resolvedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2742,7 +2814,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;
|
||||
@@ -2760,7 +2833,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);
|
||||
}
|
||||
|
||||
@@ -2777,9 +2851,16 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
// Handle CMD type healthcheck
|
||||
if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
|
||||
$this->full_healthcheck_url = $this->application->health_check_command;
|
||||
$command = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->health_check_command);
|
||||
|
||||
return $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)
|
||||
@@ -2796,20 +2877,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$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/\-_.~%]+$#', '/')
|
||||
? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%,;]+$#', '/')
|
||||
: null;
|
||||
|
||||
$url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/'));
|
||||
$method = escapeshellarg($method);
|
||||
$escapedMethod = escapeshellarg($method);
|
||||
|
||||
if ($path) {
|
||||
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}{$path}";
|
||||
$this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}{$path}";
|
||||
} else {
|
||||
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}/";
|
||||
$this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}/";
|
||||
}
|
||||
|
||||
$generated_healthchecks_commands = [
|
||||
"curl -s -X {$method} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1",
|
||||
"curl -s -X {$escapedMethod} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1",
|
||||
];
|
||||
|
||||
return implode(' ', $generated_healthchecks_commands);
|
||||
@@ -2904,7 +2985,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
private function build_image()
|
||||
{
|
||||
// Add Coolify related variables to the build args/secrets
|
||||
if (! $this->dockerBuildkitSupported) {
|
||||
if (! $this->dockerSecretsSupported) {
|
||||
// Traditional build args approach - generate COOLIFY_ variables locally
|
||||
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
|
||||
$coolify_envs->each(function ($value, $key) {
|
||||
@@ -2913,7 +2994,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
|
||||
// Always convert build_args Collection to string for command interpolation
|
||||
$this->build_args = $this->build_args instanceof \Illuminate\Support\Collection
|
||||
$this->build_args = $this->build_args instanceof Collection
|
||||
? $this->build_args->implode(' ')
|
||||
: (string) $this->build_args;
|
||||
|
||||
@@ -2994,28 +3075,29 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
|
||||
} else {
|
||||
// Dockerfile buildpack
|
||||
$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
|
||||
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} --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} --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} -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} -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);
|
||||
@@ -3490,7 +3572,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
} else {
|
||||
$secrets_string = $variables
|
||||
->map(function ($env) {
|
||||
return "{$env->key}={$env->real_value}";
|
||||
return "{$env->key}={$env->getResolvedValueWithServer($this->mainServer)}";
|
||||
})
|
||||
->sort()
|
||||
->implode('|');
|
||||
@@ -3515,8 +3597,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;
|
||||
}
|
||||
|
||||
@@ -3556,7 +3638,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
if (data_get($env, 'is_multiline') === true) {
|
||||
$argsToInsert->push("ARG {$env->key}");
|
||||
} else {
|
||||
$argsToInsert->push("ARG {$env->key}={$env->real_value}");
|
||||
$argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}");
|
||||
}
|
||||
}
|
||||
// Add Coolify variables as ARGs
|
||||
@@ -3578,7 +3660,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
if (data_get($env, 'is_multiline') === true) {
|
||||
$argsToInsert->push("ARG {$env->key}");
|
||||
} else {
|
||||
$argsToInsert->push("ARG {$env->key}={$env->real_value}");
|
||||
$argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}");
|
||||
}
|
||||
}
|
||||
// Add Coolify variables as ARGs
|
||||
@@ -3614,7 +3696,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
}
|
||||
}
|
||||
$envs_mapped = $envs->mapWithKeys(function ($env) {
|
||||
return [$env->key => $env->real_value];
|
||||
return [$env->key => $env->getResolvedValueWithServer($this->mainServer)];
|
||||
});
|
||||
$secrets_hash = $this->generate_secrets_hash($envs_mapped);
|
||||
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
|
||||
@@ -3914,7 +3996,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);
|
||||
@@ -3926,7 +4008,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
|
||||
private function validatePathField(string $value, string $fieldName): string
|
||||
{
|
||||
if (! preg_match('/^\/[a-zA-Z0-9._\-\/]+$/', $value)) {
|
||||
if (! preg_match(ValidationPatterns::FILE_PATH_PATTERN, $value)) {
|
||||
throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters.");
|
||||
}
|
||||
if (str_contains($value, '..')) {
|
||||
@@ -3936,6 +4018,69 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which container to execute a deployment command in.
|
||||
*
|
||||
* For single-container apps, returns the sole container.
|
||||
* For multi-container apps, matches by the user-specified container name.
|
||||
* If no container name is specified for multi-container apps, logs available containers and returns null.
|
||||
*/
|
||||
private function resolveCommandContainer(Collection $containers, ?string $specifiedContainerName, string $commandType): ?array
|
||||
{
|
||||
if ($containers->count() === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($containers->count() === 1) {
|
||||
return $containers->first();
|
||||
}
|
||||
|
||||
// Multi-container: require a container name to be specified
|
||||
if (empty($specifiedContainerName)) {
|
||||
$available = $containers->map(fn ($c) => data_get($c, 'Names'))->implode(', ');
|
||||
$this->application_deployment_queue->addLogEntry(
|
||||
"{$commandType} command: Multiple containers found but no container name specified. Available: {$available}"
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Multi-container: match by specified name prefix
|
||||
$prefix = $specifiedContainerName.'-'.$this->application->uuid;
|
||||
foreach ($containers as $container) {
|
||||
$containerName = data_get($container, 'Names');
|
||||
if (str_starts_with($containerName, $prefix)) {
|
||||
return $container;
|
||||
}
|
||||
}
|
||||
|
||||
// No match found — log available containers to help the user debug
|
||||
$available = $containers->map(fn ($c) => data_get($c, 'Names'))->implode(', ');
|
||||
$this->application_deployment_queue->addLogEntry(
|
||||
"{$commandType} command: Container '{$specifiedContainerName}' not found. Available: {$available}"
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function run_pre_deployment_command()
|
||||
{
|
||||
if (empty($this->application->pre_deployment_command)) {
|
||||
@@ -3943,26 +4088,39 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
}
|
||||
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
|
||||
if ($containers->count() == 0) {
|
||||
$this->application_deployment_queue->addLogEntry('Pre-deployment command: No running containers found. Skipping.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry('Executing pre-deployment command (see debug log for output/errors).');
|
||||
|
||||
foreach ($containers as $container) {
|
||||
$containerName = data_get($container, 'Names');
|
||||
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)."'";
|
||||
$exec = "docker exec {$containerName} {$cmd}";
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
'command' => $exec,
|
||||
'hidden' => true,
|
||||
],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
$container = $this->resolveCommandContainer($containers, $this->application->pre_deployment_command_container, 'Pre-deployment');
|
||||
if ($container === null) {
|
||||
throw new DeploymentException('Pre-deployment command: Could not find a valid container. Is the container name correct?');
|
||||
}
|
||||
throw new DeploymentException('Pre-deployment command: Could not find a valid container. Is the container name correct?');
|
||||
|
||||
$containerName = data_get($container, 'Names');
|
||||
if ($containerName) {
|
||||
$this->validateContainerName($containerName);
|
||||
}
|
||||
// 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(
|
||||
[
|
||||
'command' => $exec,
|
||||
'hidden' => true,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function run_post_deployment_command()
|
||||
@@ -3974,31 +4132,42 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
$this->application_deployment_queue->addLogEntry('Executing post-deployment command (see debug log for output).');
|
||||
|
||||
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
|
||||
foreach ($containers as $container) {
|
||||
$containerName = data_get($container, 'Names');
|
||||
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)."'";
|
||||
$exec = "docker exec {$containerName} {$cmd}";
|
||||
try {
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
'command' => $exec,
|
||||
'hidden' => true,
|
||||
'save' => 'post-deployment-command-output',
|
||||
],
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
$post_deployment_command_output = $this->saved_outputs->get('post-deployment-command-output');
|
||||
if ($post_deployment_command_output) {
|
||||
$this->application_deployment_queue->addLogEntry('Post-deployment command failed.');
|
||||
$this->application_deployment_queue->addLogEntry($post_deployment_command_output, 'stderr');
|
||||
}
|
||||
}
|
||||
if ($containers->count() == 0) {
|
||||
$this->application_deployment_queue->addLogEntry('Post-deployment command: No running containers found. Skipping.');
|
||||
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
$container = $this->resolveCommandContainer($containers, $this->application->post_deployment_command_container, 'Post-deployment');
|
||||
if ($container === null) {
|
||||
throw new DeploymentException('Post-deployment command: Could not find a valid container. Is the container name correct?');
|
||||
}
|
||||
|
||||
$containerName = data_get($container, 'Names');
|
||||
if ($containerName) {
|
||||
$this->validateContainerName($containerName);
|
||||
}
|
||||
// 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(
|
||||
[
|
||||
'command' => $exec,
|
||||
'hidden' => true,
|
||||
'save' => 'post-deployment-command-output',
|
||||
],
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
$post_deployment_command_output = $this->saved_outputs->get('post-deployment-command-output');
|
||||
if ($post_deployment_command_output) {
|
||||
$this->application_deployment_queue->addLogEntry('Post-deployment command failed.');
|
||||
$this->application_deployment_queue->addLogEntry($post_deployment_command_output, 'stderr');
|
||||
}
|
||||
}
|
||||
throw new DeploymentException('Post-deployment command: Could not find a valid container. Is the container name correct?');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -12,6 +13,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
|
||||
@@ -32,6 +34,7 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
|
||||
try {
|
||||
$this->cleanupInvitationLink();
|
||||
$this->cleanupExpiredEmailChangeRequests();
|
||||
$this->enforceBackupRetention();
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('CleanupInstanceStuffsJob failed with error: '.$e->getMessage());
|
||||
}
|
||||
@@ -55,4 +58,25 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
|
||||
'email_change_code_expires_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function enforceBackupRetention(): void
|
||||
{
|
||||
if (! Cache::add('backup-retention-enforcement', true, 1800)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$backups = ScheduledDatabaseBackup::where('enabled', true)->get();
|
||||
foreach ($backups as $backup) {
|
||||
try {
|
||||
removeOldBackups($backup);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to enforce retention for backup '.$backup->id.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to enforce backup retention: '.$e->getMessage());
|
||||
Cache::forget('backup-retention-enforcement');
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
-48
@@ -22,6 +22,7 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -80,6 +81,13 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->timeout = $backup->timeout ?? 3600;
|
||||
}
|
||||
|
||||
public function middleware(): array
|
||||
{
|
||||
$expireAfter = ($this->backup->timeout ?? 3600) + 300;
|
||||
|
||||
return [(new WithoutOverlapping('database-backup-'.$this->backup->id))->expireAfter($expireAfter)->dontRelease()];
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
@@ -91,7 +99,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;
|
||||
@@ -107,6 +115,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
throw new \Exception('Database not found?!');
|
||||
}
|
||||
|
||||
$this->markStaleExecutionsAsFailed();
|
||||
|
||||
BackupCreated::dispatch($this->team->id);
|
||||
|
||||
$status = str(data_get($this->database, 'status'));
|
||||
@@ -119,7 +129,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) {
|
||||
$databaseType = $this->database->databaseType();
|
||||
$serviceUuid = $this->database->service->uuid;
|
||||
$serviceName = str($this->database->service->name)->slug();
|
||||
@@ -241,7 +251,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
// Continue without env vars - will be handled in backup_standalone_mongodb method
|
||||
}
|
||||
}
|
||||
@@ -388,7 +398,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([
|
||||
@@ -399,7 +409,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;
|
||||
}
|
||||
@@ -415,7 +433,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();
|
||||
}
|
||||
@@ -439,18 +457,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) {
|
||||
@@ -472,19 +499,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)]);
|
||||
$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(':')) {
|
||||
@@ -502,15 +533,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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,7 +558,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;
|
||||
}
|
||||
@@ -531,15 +570,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;
|
||||
@@ -548,7 +588,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;
|
||||
}
|
||||
@@ -558,20 +598,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;
|
||||
}
|
||||
@@ -581,20 +622,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;
|
||||
}
|
||||
@@ -625,21 +667,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();
|
||||
|
||||
@@ -651,13 +700,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
|
||||
@@ -670,7 +719,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;
|
||||
@@ -688,6 +737,31 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return "{$helperImage}:{$latestVersion}";
|
||||
}
|
||||
|
||||
private function markStaleExecutionsAsFailed(): void
|
||||
{
|
||||
try {
|
||||
$timeoutSeconds = ($this->backup->timeout ?? 3600) * 2;
|
||||
|
||||
$staleExecutions = $this->backup->executions()
|
||||
->where('status', 'running')
|
||||
->where('created_at', '<', now()->subSeconds($timeoutSeconds))
|
||||
->get();
|
||||
|
||||
foreach ($staleExecutions as $execution) {
|
||||
$execution->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed - backup execution exceeded maximum allowed time',
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::channel('scheduled-errors')->warning('Failed to clean up stale backup executions', [
|
||||
'backup_id' => $this->backup->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
Log::channel('scheduled-errors')->error('DatabaseBackup permanently failed', [
|
||||
@@ -704,20 +778,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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -307,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;
|
||||
@@ -321,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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -371,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;
|
||||
@@ -386,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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,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()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,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()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,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) {
|
||||
@@ -545,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);
|
||||
@@ -555,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -185,7 +184,7 @@ class ScheduledJobManager implements ShouldQueue
|
||||
$frequency = VALID_CRON_STRINGS[$frequency];
|
||||
}
|
||||
|
||||
if ($this->shouldRunNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}")) {
|
||||
if (shouldRunCronNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}", $this->executionTime)) {
|
||||
DatabaseBackupJob::dispatch($backup);
|
||||
$this->dispatchedCount++;
|
||||
Log::channel('scheduled')->info('Backup dispatched', [
|
||||
@@ -239,7 +238,7 @@ class ScheduledJobManager implements ShouldQueue
|
||||
$frequency = VALID_CRON_STRINGS[$frequency];
|
||||
}
|
||||
|
||||
if (! $this->shouldRunNow($frequency, $serverTimezone, "scheduled-task:{$task->id}")) {
|
||||
if (! shouldRunCronNow($frequency, $serverTimezone, "scheduled-task:{$task->id}", $this->executionTime)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -336,51 +335,6 @@ class ScheduledJobManager implements ShouldQueue
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a cron schedule should run now.
|
||||
*
|
||||
* When a dedupKey is provided, uses getPreviousRunDate() + last-dispatch tracking
|
||||
* instead of isDue(). This is resilient to queue delays — even if the job is delayed
|
||||
* by minutes, it still catches the missed cron window. Without dedupKey, falls back
|
||||
* to simple isDue() check.
|
||||
*/
|
||||
private function shouldRunNow(string $frequency, string $timezone, ?string $dedupKey = null): bool
|
||||
{
|
||||
$cron = new CronExpression($frequency);
|
||||
$baseTime = $this->executionTime ?? Carbon::now();
|
||||
$executionTime = $baseTime->copy()->setTimezone($timezone);
|
||||
|
||||
// No dedup key → simple isDue check (used by docker cleanups)
|
||||
if ($dedupKey === null) {
|
||||
return $cron->isDue($executionTime);
|
||||
}
|
||||
|
||||
// Get the most recent time this cron was due (including current minute)
|
||||
$previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
|
||||
|
||||
$lastDispatched = Cache::get($dedupKey);
|
||||
|
||||
if ($lastDispatched === null) {
|
||||
// First run after restart or cache loss: only fire if actually due right now.
|
||||
// Seed the cache so subsequent runs can use tolerance/catch-up logic.
|
||||
$isDue = $cron->isDue($executionTime);
|
||||
if ($isDue) {
|
||||
Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
|
||||
}
|
||||
|
||||
return $isDue;
|
||||
}
|
||||
|
||||
// Subsequent runs: fire if there's been a due time since last dispatch
|
||||
if ($previousDue->gt(Carbon::parse($lastDispatched))) {
|
||||
Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function processDockerCleanups(): void
|
||||
{
|
||||
// Get all servers that need cleanup checks
|
||||
@@ -411,7 +365,7 @@ 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Queue\TimeoutExceededException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
@@ -36,11 +37,12 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
|
||||
if ($exception instanceof TimeoutExceededException) {
|
||||
Log::warning('ServerCheckJob timed out', [
|
||||
'server_id' => $this->server->id,
|
||||
'server_name' => $this->server->name,
|
||||
]);
|
||||
$this->server->increment('unreachable_count');
|
||||
|
||||
// Delete the queue job so it doesn't appear in Horizon's failed list.
|
||||
$this->job?->delete();
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use App\Models\Server;
|
||||
use App\Services\ConfigurationRepository;
|
||||
use App\Services\HetznerService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -11,7 +13,9 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Queue\TimeoutExceededException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
@@ -19,7 +23,7 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
public $tries = 1;
|
||||
|
||||
public $timeout = 30;
|
||||
public $timeout = 15;
|
||||
|
||||
public function __construct(
|
||||
public Server $server,
|
||||
@@ -28,7 +32,7 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
public function middleware(): array
|
||||
{
|
||||
return [(new WithoutOverlapping('server-connection-check-'.$this->server->uuid))->expireAfter(45)->dontRelease()];
|
||||
return [(new WithoutOverlapping('server-connection-check-'.$this->server->uuid))->expireAfter(25)->dontRelease()];
|
||||
}
|
||||
|
||||
private function disableSshMux(): void
|
||||
@@ -72,6 +76,7 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
'is_reachable' => false,
|
||||
'is_usable' => false,
|
||||
]);
|
||||
$this->server->increment('unreachable_count');
|
||||
|
||||
Log::warning('ServerConnectionCheck: Server not reachable', [
|
||||
'server_id' => $this->server->id,
|
||||
@@ -90,6 +95,10 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
'is_usable' => $isUsable,
|
||||
]);
|
||||
|
||||
if ($this->server->unreachable_count > 0) {
|
||||
$this->server->update(['unreachable_count' => 0]);
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
Log::error('ServerConnectionCheckJob failed', [
|
||||
@@ -100,6 +109,7 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
'is_reachable' => false,
|
||||
'is_usable' => false,
|
||||
]);
|
||||
$this->server->increment('unreachable_count');
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -107,15 +117,12 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
|
||||
Log::warning('ServerConnectionCheckJob timed out', [
|
||||
'server_id' => $this->server->id,
|
||||
'server_name' => $this->server->name,
|
||||
]);
|
||||
if ($exception instanceof TimeoutExceededException) {
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => false,
|
||||
'is_usable' => false,
|
||||
]);
|
||||
$this->server->increment('unreachable_count');
|
||||
|
||||
// Delete the queue job so it doesn't appear in Horizon's failed list.
|
||||
$this->job?->delete();
|
||||
@@ -127,15 +134,12 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$status = null;
|
||||
|
||||
try {
|
||||
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
|
||||
$hetznerService = new 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]);
|
||||
@@ -151,15 +155,18 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
private function checkConnection(): bool
|
||||
{
|
||||
try {
|
||||
// Use instant_remote_process with a simple command
|
||||
// This will automatically handle mux, sudo, IPv6, Cloudflare tunnel, etc.
|
||||
$output = instant_remote_process_with_timeout(
|
||||
['ls -la /'],
|
||||
$this->server,
|
||||
false // don't throw error
|
||||
);
|
||||
// Single SSH attempt without SshRetryHandler — retries waste time for connectivity checks.
|
||||
// Backoff is managed at the dispatch level via unreachable_count.
|
||||
$commands = ['ls -la /'];
|
||||
if ($this->server->isNonRoot()) {
|
||||
$commands = parseCommandsByLineForSudo(collect($commands), $this->server);
|
||||
}
|
||||
$commandString = implode("\n", $commands);
|
||||
|
||||
return $output !== null;
|
||||
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $commandString, true);
|
||||
$process = Process::timeout(10)->run($sshCommand);
|
||||
|
||||
return $process->exitCode() === 0;
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('ServerConnectionCheck: Connection check failed', [
|
||||
'server_id' => $this->server->id,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -79,13 +79,16 @@ 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;
|
||||
}
|
||||
if ($this->shouldSkipDueToBackoff($server)) {
|
||||
return;
|
||||
}
|
||||
ServerConnectionCheckJob::dispatch($server);
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [
|
||||
@@ -128,13 +131,15 @@ class ServerManagerJob implements ShouldQueue
|
||||
|
||||
if ($sentinelOutOfSync) {
|
||||
// Dispatch ServerCheckJob if Sentinel is out of sync
|
||||
if ($this->shouldRunNow($this->checkFrequency, $serverTimezone)) {
|
||||
ServerCheckJob::dispatch($server);
|
||||
if (shouldRunCronNow($this->checkFrequency, $serverTimezone, "server-check:{$server->id}", $this->executionTime)) {
|
||||
if (! $this->shouldSkipDueToBackoff($server)) {
|
||||
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) {
|
||||
@@ -148,7 +153,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);
|
||||
@@ -156,7 +161,7 @@ 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);
|
||||
@@ -166,14 +171,38 @@ class ServerManagerJob implements ShouldQueue
|
||||
// Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob.
|
||||
}
|
||||
|
||||
private function shouldRunNow(string $frequency, ?string $timezone = null): bool
|
||||
/**
|
||||
* Determine the backoff cycle interval based on how many consecutive times a server has been unreachable.
|
||||
* Higher counts → less frequent checks (based on 5-min cloud cycle):
|
||||
* 0-2: every cycle, 3-5: ~15 min, 6-11: ~30 min, 12+: ~60 min
|
||||
*/
|
||||
private function getBackoffCycleInterval(int $unreachableCount): int
|
||||
{
|
||||
$cron = new CronExpression($frequency);
|
||||
return match (true) {
|
||||
$unreachableCount <= 2 => 1,
|
||||
$unreachableCount <= 5 => 3,
|
||||
$unreachableCount <= 11 => 6,
|
||||
default => 12,
|
||||
};
|
||||
}
|
||||
|
||||
// Use the frozen execution time, not the current time
|
||||
$baseTime = $this->executionTime ?? Carbon::now();
|
||||
$executionTime = $baseTime->copy()->setTimezone($timezone ?? config('app.timezone'));
|
||||
/**
|
||||
* Check if a server should be skipped this cycle due to unreachable backoff.
|
||||
* Uses server ID hash to distribute checks across cycles (avoid thundering herd).
|
||||
*/
|
||||
private function shouldSkipDueToBackoff(Server $server): bool
|
||||
{
|
||||
$unreachableCount = $server->unreachable_count ?? 0;
|
||||
$interval = $this->getBackoffCycleInterval($unreachableCount);
|
||||
|
||||
return $cron->isDue($executionTime);
|
||||
if ($interval <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cyclePeriodMinutes = isCloud() ? 5 : 1;
|
||||
$cycleIndex = intdiv($this->executionTime->minute, $cyclePeriodMinutes);
|
||||
$serverHash = abs(crc32((string) $server->id));
|
||||
|
||||
return ($cycleIndex + $serverHash) % $interval !== 0;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
@@ -39,15 +37,13 @@ class Index extends Component
|
||||
Auth::login($user);
|
||||
refreshSession($team_to_switch_to);
|
||||
|
||||
return redirect(request()->header('Referer'));
|
||||
return redirect()->route('admin.index');
|
||||
}
|
||||
}
|
||||
|
||||
public function submitSearch()
|
||||
{
|
||||
if (Auth::id() !== 0 && ! session('impersonating')) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
$this->authorizeAdminAccess();
|
||||
if ($this->search !== '') {
|
||||
$this->foundUsers = User::where(function ($query) {
|
||||
$query->where('name', 'like', "%{$this->search}%")
|
||||
@@ -67,17 +63,31 @@ 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'));
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\Team;
|
||||
use App\Services\ConfigurationRepository;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
@@ -22,18 +23,18 @@ class Index extends Component
|
||||
'prerequisitesInstalled' => 'handlePrerequisitesInstalled',
|
||||
];
|
||||
|
||||
#[\Livewire\Attributes\Url(as: 'step', history: true)]
|
||||
#[Url(as: 'step', history: true)]
|
||||
public string $currentState = 'welcome';
|
||||
|
||||
#[\Livewire\Attributes\Url(keep: true)]
|
||||
#[Url(keep: true)]
|
||||
public ?string $selectedServerType = null;
|
||||
|
||||
public ?Collection $privateKeys = null;
|
||||
|
||||
#[\Livewire\Attributes\Url(keep: true)]
|
||||
#[Url(keep: true)]
|
||||
public ?int $selectedExistingPrivateKey = null;
|
||||
|
||||
#[\Livewire\Attributes\Url(keep: true)]
|
||||
#[Url(keep: true)]
|
||||
public ?string $privateKeyType = null;
|
||||
|
||||
public ?string $privateKey = null;
|
||||
@@ -48,7 +49,7 @@ class Index extends Component
|
||||
|
||||
public ?Collection $servers = null;
|
||||
|
||||
#[\Livewire\Attributes\Url(keep: true)]
|
||||
#[Url(keep: true)]
|
||||
public ?int $selectedExistingServer = null;
|
||||
|
||||
public ?string $remoteServerName = null;
|
||||
@@ -69,7 +70,7 @@ class Index extends Component
|
||||
|
||||
public Collection $projects;
|
||||
|
||||
#[\Livewire\Attributes\Url(keep: true)]
|
||||
#[Url(keep: true)]
|
||||
public ?int $selectedProject = null;
|
||||
|
||||
public ?Project $createdProject = null;
|
||||
@@ -124,7 +125,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();
|
||||
@@ -148,7 +149,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();
|
||||
}
|
||||
@@ -444,7 +445,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';
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,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'])]
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Livewire\Destination;
|
||||
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Validate;
|
||||
@@ -19,7 +18,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'])]
|
||||
@@ -28,9 +27,10 @@ class Show extends Component
|
||||
public function mount(string $destination_uuid)
|
||||
{
|
||||
try {
|
||||
$destination = StandaloneDocker::whereUuid($destination_uuid)->first() ??
|
||||
SwarmDocker::whereUuid($destination_uuid)->firstOrFail();
|
||||
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
return redirect()->route('destination.index');
|
||||
}
|
||||
$this->authorize('view', $destination);
|
||||
|
||||
$this->destination = $destination;
|
||||
@@ -74,12 +74,13 @@ class Show extends Component
|
||||
try {
|
||||
$this->authorize('delete', $this->destination);
|
||||
|
||||
if ($this->destination->getMorphClass() === \App\Models\StandaloneDocker::class) {
|
||||
if ($this->destination->getMorphClass() === StandaloneDocker::class) {
|
||||
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();
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ class ForcePasswordReset extends Component
|
||||
$this->rateLimit(10);
|
||||
$this->validate();
|
||||
$firstLogin = auth()->user()->created_at == auth()->user()->updated_at;
|
||||
auth()->user()->forceFill([
|
||||
auth()->user()->fill([
|
||||
'password' => Hash::make($this->password),
|
||||
'force_password_reset' => false,
|
||||
])->save();
|
||||
|
||||
@@ -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;
|
||||
@@ -1496,7 +1496,10 @@ class GlobalSearch extends Component
|
||||
'category' => 'Services',
|
||||
'resourceType' => 'service',
|
||||
'logo' => data_get($service, 'logo'),
|
||||
]);
|
||||
] + array_filter([
|
||||
'amd_only' => data_get($service, 'amd_only') ? true : null,
|
||||
'arm_only' => data_get($service, 'arm_only') ? true : null,
|
||||
]));
|
||||
}
|
||||
|
||||
$cachedServices = $items->toArray();
|
||||
|
||||
@@ -15,7 +15,7 @@ class Help extends Component
|
||||
#[Validate(['required', 'min:10', 'max:1000'])]
|
||||
public string $description;
|
||||
|
||||
#[Validate(['required', 'min:3'])]
|
||||
#[Validate(['required', 'min:3', 'max:600'])]
|
||||
public string $subject;
|
||||
|
||||
public function submit()
|
||||
|
||||
@@ -19,11 +19,11 @@ class NavbarDeleteTeam extends Component
|
||||
$this->team = currentTeam()->name;
|
||||
}
|
||||
|
||||
public function delete($password)
|
||||
public function delete($password, $selectedActions = [])
|
||||
{
|
||||
try {
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return;
|
||||
return 'The provided password is incorrect.';
|
||||
}
|
||||
|
||||
$currentTeam = currentTeam();
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -42,7 +42,7 @@ class Email extends Component
|
||||
public ?string $smtpHost = null;
|
||||
|
||||
#[Validate(['nullable', 'numeric', 'min:1', 'max:65535'])]
|
||||
public ?int $smtpPort = null;
|
||||
public ?string $smtpPort = null;
|
||||
|
||||
#[Validate(['nullable', 'string', 'in:starttls,tls,none'])]
|
||||
public ?string $smtpEncryption = null;
|
||||
@@ -54,7 +54,7 @@ class Email extends Component
|
||||
public ?string $smtpPassword = null;
|
||||
|
||||
#[Validate(['nullable', 'numeric'])]
|
||||
public ?int $smtpTimeout = null;
|
||||
public ?string $smtpTimeout = null;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $resendEnabled = false;
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])]
|
||||
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', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])]
|
||||
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',
|
||||
'portsExposes' => 'required',
|
||||
'portsMappings' => 'nullable',
|
||||
'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)),
|
||||
'publishDirectory' => ValidationPatterns::directoryPathRules(),
|
||||
'portsExposes' => ['required', 'string', 'regex:/^(\d+)(,\d+)*$/'],
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'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.',
|
||||
@@ -238,6 +212,8 @@ class General extends Component
|
||||
'staticImage.required' => 'The Static Image field is required.',
|
||||
'baseDirectory.required' => 'The Base Directory field is required.',
|
||||
'portsExposes.required' => 'The Exposed Ports field is required.',
|
||||
'portsExposes.regex' => 'Ports exposes must be a comma-separated list of port numbers (e.g. 3000,3001).',
|
||||
...ValidationPatterns::portMappingMessages(),
|
||||
'isStatic.required' => 'The Static setting is required.',
|
||||
'isStatic.boolean' => 'The Static setting must be true or false.',
|
||||
'isSpa.required' => 'The SPA setting is required.',
|
||||
@@ -320,7 +296,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 +317,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 +329,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 +595,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 +620,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 +737,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).');
|
||||
@@ -781,6 +758,12 @@ class General extends Component
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
$this->resetErrorBag();
|
||||
|
||||
$this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString();
|
||||
if ($this->portsMappings) {
|
||||
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
|
||||
}
|
||||
|
||||
$this->validate();
|
||||
|
||||
$oldPortsExposes = $this->application->ports_exposes;
|
||||
@@ -841,7 +824,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 +954,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
|
||||
|
||||
@@ -35,8 +35,17 @@ class Previews extends Component
|
||||
|
||||
public array $previewFqdns = [];
|
||||
|
||||
public array $previewDockerTags = [];
|
||||
|
||||
public ?int $manualPullRequestId = null;
|
||||
|
||||
public ?string $manualDockerTag = null;
|
||||
|
||||
protected $rules = [
|
||||
'previewFqdns.*' => 'string|nullable',
|
||||
'previewDockerTags.*' => 'string|nullable',
|
||||
'manualPullRequestId' => 'integer|min:1|nullable',
|
||||
'manualDockerTag' => 'string|nullable',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
@@ -53,12 +62,17 @@ class Previews extends Component
|
||||
$preview = $this->application->previews->get($key);
|
||||
if ($preview) {
|
||||
$preview->fqdn = $fqdn;
|
||||
if ($this->application->build_pack === 'dockerimage') {
|
||||
$preview->docker_registry_image_tag = $this->previewDockerTags[$key] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->previewFqdns = [];
|
||||
$this->previewDockerTags = [];
|
||||
foreach ($this->application->previews as $key => $preview) {
|
||||
$this->previewFqdns[$key] = $preview->fqdn;
|
||||
$this->previewDockerTags[$key] = $preview->docker_registry_image_tag;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,7 +188,7 @@ class Previews extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function add(int $pull_request_id, ?string $pull_request_html_url = null)
|
||||
public function add(int $pull_request_id, ?string $pull_request_html_url = null, ?string $docker_registry_image_tag = null)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
@@ -195,13 +209,18 @@ class Previews extends Component
|
||||
} else {
|
||||
$this->setDeploymentUuid();
|
||||
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found && ! is_null($pull_request_html_url)) {
|
||||
if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) {
|
||||
$found = ApplicationPreview::create([
|
||||
'application_id' => $this->application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'pull_request_html_url' => $pull_request_html_url ?? '',
|
||||
'docker_registry_image_tag' => $docker_registry_image_tag,
|
||||
]);
|
||||
}
|
||||
if ($found && $this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()) {
|
||||
$found->docker_registry_image_tag = $docker_registry_image_tag;
|
||||
$found->save();
|
||||
}
|
||||
$found->generate_preview_fqdn();
|
||||
$this->application->refresh();
|
||||
$this->syncData(false);
|
||||
@@ -218,43 +237,56 @@ class Previews extends Component
|
||||
try {
|
||||
$this->authorize('deploy', $this->application);
|
||||
|
||||
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true);
|
||||
$dockerRegistryImageTag = null;
|
||||
if ($this->application->build_pack === 'dockerimage') {
|
||||
$dockerRegistryImageTag = $this->application->previews()
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->value('docker_registry_image_tag');
|
||||
}
|
||||
|
||||
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true, docker_registry_image_tag: $dockerRegistryImageTag);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null)
|
||||
public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null, ?string $docker_registry_image_tag = null)
|
||||
{
|
||||
try {
|
||||
$this->authorize('deploy', $this->application);
|
||||
|
||||
$this->add($pull_request_id, $pull_request_html_url);
|
||||
$this->deploy($pull_request_id, $pull_request_html_url);
|
||||
$this->add($pull_request_id, $pull_request_html_url, $docker_registry_image_tag);
|
||||
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: false, docker_registry_image_tag: $docker_registry_image_tag);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false)
|
||||
public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false, ?string $docker_registry_image_tag = null)
|
||||
{
|
||||
try {
|
||||
$this->authorize('deploy', $this->application);
|
||||
$this->setDeploymentUuid();
|
||||
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found && ! is_null($pull_request_html_url)) {
|
||||
ApplicationPreview::create([
|
||||
if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) {
|
||||
$found = ApplicationPreview::create([
|
||||
'application_id' => $this->application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'pull_request_html_url' => $pull_request_html_url ?? '',
|
||||
'docker_registry_image_tag' => $docker_registry_image_tag,
|
||||
]);
|
||||
}
|
||||
if ($found && $this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()) {
|
||||
$found->docker_registry_image_tag = $docker_registry_image_tag;
|
||||
$found->save();
|
||||
}
|
||||
$result = queue_application_deployment(
|
||||
application: $this->application,
|
||||
deployment_uuid: $this->deployment_uuid,
|
||||
force_rebuild: $force_rebuild,
|
||||
pull_request_id: $pull_request_id,
|
||||
git_type: $found->git_type ?? null,
|
||||
docker_registry_image_tag: $docker_registry_image_tag,
|
||||
);
|
||||
if ($result['status'] === 'queue_full') {
|
||||
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||
@@ -284,6 +316,32 @@ class Previews extends Component
|
||||
$this->parameters['deployment_uuid'] = $this->deployment_uuid;
|
||||
}
|
||||
|
||||
public function addDockerImagePreview()
|
||||
{
|
||||
$this->authorize('deploy', $this->application);
|
||||
$this->validateOnly('manualPullRequestId');
|
||||
$this->validateOnly('manualDockerTag');
|
||||
|
||||
if ($this->application->build_pack !== 'dockerimage') {
|
||||
$this->dispatch('error', 'Manual Docker Image previews are only available for Docker Image applications.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->manualPullRequestId === null || str($this->manualDockerTag)->isEmpty()) {
|
||||
$this->dispatch('error', 'Both pull request id and docker tag are required.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$dockerTag = str($this->manualDockerTag)->trim()->value();
|
||||
|
||||
$this->add_and_deploy($this->manualPullRequestId, null, $dockerTag);
|
||||
|
||||
$this->manualPullRequestId = null;
|
||||
$this->manualDockerTag = null;
|
||||
}
|
||||
|
||||
private function stopContainers(array $containers, $server)
|
||||
{
|
||||
$containersToStop = collect($containers)->pluck('Names')->toArray();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -57,7 +57,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()
|
||||
@@ -191,6 +191,7 @@ class CloneMe extends Component
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'uuid',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $newDatabase->id,
|
||||
@@ -302,9 +303,9 @@ class CloneMe extends Component
|
||||
}
|
||||
|
||||
foreach ($newService->applications() as $application) {
|
||||
$application->update([
|
||||
$application->fill([
|
||||
'status' => 'exited',
|
||||
]);
|
||||
])->save();
|
||||
|
||||
$persistentVolumes = $application->persistentStorages()->get();
|
||||
foreach ($persistentVolumes as $volume) {
|
||||
@@ -319,6 +320,7 @@ class CloneMe extends Component
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'uuid',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $application->id,
|
||||
@@ -356,9 +358,9 @@ class CloneMe extends Component
|
||||
}
|
||||
|
||||
foreach ($newService->databases() as $database) {
|
||||
$database->update([
|
||||
$database->fill([
|
||||
'status' => 'exited',
|
||||
]);
|
||||
])->save();
|
||||
|
||||
$persistentVolumes = $database->persistentStorages()->get();
|
||||
foreach ($persistentVolumes as $volume) {
|
||||
@@ -373,6 +375,7 @@ class CloneMe extends Component
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'uuid',
|
||||
])->fill([
|
||||
'name' => $newName,
|
||||
'resource_id' => $database->id,
|
||||
|
||||
@@ -76,7 +76,7 @@ class BackupEdit extends Component
|
||||
public bool $dumpAll = false;
|
||||
|
||||
#[Validate(['required', 'int', 'min:60', 'max:36000'])]
|
||||
public int $timeout = 3600;
|
||||
public int|string $timeout = 3600;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
@@ -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 {
|
||||
|
||||
@@ -78,7 +78,7 @@ class BackupExecutions extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteBackup($executionId, $password)
|
||||
public function deleteBackup($executionId, $password, $selectedActions = [])
|
||||
{
|
||||
try {
|
||||
$this->authorize('manageBackups', $this->database);
|
||||
@@ -87,7 +87,7 @@ class BackupExecutions extends Component
|
||||
}
|
||||
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return;
|
||||
return 'The provided password is incorrect.';
|
||||
}
|
||||
|
||||
$execution = $this->backup->executions()->where('id', $executionId)->first();
|
||||
@@ -115,7 +115,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)
|
||||
|
||||
@@ -34,9 +34,9 @@ class General extends Component
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
public mixed $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
public mixed $publicPortTimeout = 3600;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
||||
@@ -88,9 +88,9 @@ class General extends Component
|
||||
'clickhouseAdminUser' => 'required|string',
|
||||
'clickhouseAdminPassword' => 'required|string',
|
||||
'image' => 'required|string',
|
||||
'portsMappings' => 'nullable|string',
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'customDockerRunOptions' => 'nullable|string',
|
||||
'dbUrl' => 'nullable|string',
|
||||
@@ -103,6 +103,7 @@ class General extends Component
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
ValidationPatterns::portMappingMessages(),
|
||||
[
|
||||
'clickhouseAdminUser.required' => 'The Admin User field is required.',
|
||||
'clickhouseAdminUser.string' => 'The Admin User must be a string.',
|
||||
@@ -111,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.',
|
||||
'publicPort.min' => 'The Public Port must be at least 1.',
|
||||
'publicPort.max' => 'The Public Port must not exceed 65535.',
|
||||
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
|
||||
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
|
||||
]
|
||||
@@ -128,8 +131,8 @@ class General extends Component
|
||||
$this->database->image = $this->image;
|
||||
$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->public_port = $this->publicPort ?: null;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->save();
|
||||
@@ -216,6 +219,9 @@ class General extends Component
|
||||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
if ($this->portsMappings) {
|
||||
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
|
||||
}
|
||||
if (str($this->publicPort)->isEmpty()) {
|
||||
$this->publicPort = null;
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@ class General extends Component
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
public mixed $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
public mixed $publicPortTimeout = 3600;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
||||
@@ -59,7 +59,8 @@ class General extends Component
|
||||
|
||||
return [
|
||||
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
|
||||
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -99,9 +100,9 @@ class General extends Component
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'dragonflyPassword' => 'required|string',
|
||||
'image' => 'required|string',
|
||||
'portsMappings' => 'nullable|string',
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'customDockerRunOptions' => 'nullable|string',
|
||||
'dbUrl' => 'nullable|string',
|
||||
@@ -115,12 +116,15 @@ class General extends Component
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
ValidationPatterns::portMappingMessages(),
|
||||
[
|
||||
'dragonflyPassword.required' => 'The Dragonfly Password field is required.',
|
||||
'dragonflyPassword.string' => 'The Dragonfly Password must be a string.',
|
||||
'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.',
|
||||
'publicPort.min' => 'The Public Port must be at least 1.',
|
||||
'publicPort.max' => 'The Public Port must not exceed 65535.',
|
||||
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
|
||||
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
|
||||
]
|
||||
@@ -137,8 +141,8 @@ class General extends Component
|
||||
$this->database->image = $this->image;
|
||||
$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->public_port = $this->publicPort ?: null;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->enable_ssl = $this->enable_ssl;
|
||||
@@ -226,6 +230,9 @@ class General extends Component
|
||||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
if ($this->portsMappings) {
|
||||
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
|
||||
}
|
||||
if (str($this->publicPort)->isEmpty()) {
|
||||
$this->publicPort = null;
|
||||
}
|
||||
@@ -285,8 +292,8 @@ class General extends Component
|
||||
}
|
||||
|
||||
SslHelper::generateSslCertificate(
|
||||
commonName: $existingCert->commonName,
|
||||
subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [],
|
||||
commonName: $existingCert->common_name,
|
||||
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
|
||||
resourceType: $existingCert->resource_type,
|
||||
resourceId: $existingCert->resource_id,
|
||||
serverId: $existingCert->server_id,
|
||||
@@ -302,4 +309,10 @@ class General extends Component
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
$this->database->refresh();
|
||||
$this->syncData();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -409,6 +417,12 @@ EOD;
|
||||
|
||||
$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.');
|
||||
|
||||
@@ -593,6 +607,12 @@ EOD;
|
||||
|
||||
$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.');
|
||||
|
||||
|
||||
@@ -36,9 +36,9 @@ class General extends Component
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
public mixed $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
public mixed $publicPortTimeout = 3600;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
||||
@@ -61,7 +61,8 @@ class General extends Component
|
||||
|
||||
return [
|
||||
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
|
||||
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -102,9 +103,9 @@ class General extends Component
|
||||
'keydbConf' => 'nullable|string',
|
||||
'keydbPassword' => 'required|string',
|
||||
'image' => 'required|string',
|
||||
'portsMappings' => 'nullable|string',
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'customDockerRunOptions' => 'nullable|string',
|
||||
'dbUrl' => 'nullable|string',
|
||||
@@ -120,12 +121,15 @@ class General extends Component
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
ValidationPatterns::portMappingMessages(),
|
||||
[
|
||||
'keydbPassword.required' => 'The KeyDB Password field is required.',
|
||||
'keydbPassword.string' => 'The KeyDB Password must be a string.',
|
||||
'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.',
|
||||
'publicPort.min' => 'The Public Port must be at least 1.',
|
||||
'publicPort.max' => 'The Public Port must not exceed 65535.',
|
||||
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
|
||||
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
|
||||
]
|
||||
@@ -143,8 +147,8 @@ class General extends Component
|
||||
$this->database->image = $this->image;
|
||||
$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->public_port = $this->publicPort ?: null;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->enable_ssl = $this->enable_ssl;
|
||||
@@ -233,6 +237,9 @@ class General extends Component
|
||||
try {
|
||||
$this->authorize('manageEnvironment', $this->database);
|
||||
|
||||
if ($this->portsMappings) {
|
||||
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
|
||||
}
|
||||
if (str($this->publicPort)->isEmpty()) {
|
||||
$this->publicPort = null;
|
||||
}
|
||||
@@ -278,9 +285,20 @@ class General extends Component
|
||||
->where('is_ca_certificate', true)
|
||||
->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$this->server->generateCaCertificate();
|
||||
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SslHelper::generateSslCertificate(
|
||||
commonName: $existingCert->commonName,
|
||||
subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [],
|
||||
commonName: $existingCert->common_name,
|
||||
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
|
||||
resourceType: $existingCert->resource_type,
|
||||
resourceId: $existingCert->resource_id,
|
||||
serverId: $existingCert->server_id,
|
||||
@@ -296,4 +314,10 @@ class General extends Component
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
$this->database->refresh();
|
||||
$this->syncData();
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,9 @@ class General extends Component
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
public mixed $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
public mixed $publicPortTimeout = 3600;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
@@ -63,9 +63,11 @@ class General extends Component
|
||||
public function getListeners()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$teamId = Auth::user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
|
||||
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -80,9 +82,9 @@ class General extends Component
|
||||
'mariadbDatabase' => 'required',
|
||||
'mariadbConf' => 'nullable',
|
||||
'image' => 'required',
|
||||
'portsMappings' => 'nullable',
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
@@ -94,6 +96,7 @@ class General extends Component
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
ValidationPatterns::portMappingMessages(),
|
||||
[
|
||||
'name.required' => 'The Name field is required.',
|
||||
'mariadbRootPassword.required' => 'The Root Password field is required.',
|
||||
@@ -102,6 +105,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.',
|
||||
'publicPort.min' => 'The Public Port must be at least 1.',
|
||||
'publicPort.max' => 'The Public Port must not exceed 65535.',
|
||||
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
|
||||
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
|
||||
]
|
||||
@@ -169,8 +174,8 @@ class General extends Component
|
||||
$this->database->image = $this->image;
|
||||
$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->public_port = $this->publicPort ?: null;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->enable_ssl = $this->enableSsl;
|
||||
@@ -223,6 +228,9 @@ class General extends Component
|
||||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
if ($this->portsMappings) {
|
||||
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
|
||||
}
|
||||
if (str($this->publicPort)->isEmpty()) {
|
||||
$this->publicPort = null;
|
||||
}
|
||||
@@ -299,6 +307,17 @@ class General extends Component
|
||||
|
||||
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$this->server->generateCaCertificate();
|
||||
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SslHelper::generateSslCertificate(
|
||||
commonName: $existingCert->common_name,
|
||||
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
|
||||
|
||||
@@ -40,9 +40,9 @@ class General extends Component
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
public mixed $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
public mixed $publicPortTimeout = 3600;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
@@ -63,9 +63,11 @@ class General extends Component
|
||||
public function getListeners()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$teamId = Auth::user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
|
||||
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -79,9 +81,9 @@ class General extends Component
|
||||
'mongoInitdbRootPassword' => 'required',
|
||||
'mongoInitdbDatabase' => 'required',
|
||||
'image' => 'required',
|
||||
'portsMappings' => 'nullable',
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
@@ -94,6 +96,7 @@ class General extends Component
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
ValidationPatterns::portMappingMessages(),
|
||||
[
|
||||
'name.required' => 'The Name field is required.',
|
||||
'mongoInitdbRootUsername.required' => 'The Root Username field is required.',
|
||||
@@ -101,6 +104,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.',
|
||||
'publicPort.min' => 'The Public Port must be at least 1.',
|
||||
'publicPort.max' => 'The Public Port must not exceed 65535.',
|
||||
'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.',
|
||||
@@ -167,8 +172,8 @@ class General extends Component
|
||||
$this->database->image = $this->image;
|
||||
$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->public_port = $this->publicPort ?: null;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->enable_ssl = $this->enableSsl;
|
||||
@@ -222,6 +227,9 @@ class General extends Component
|
||||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
if ($this->portsMappings) {
|
||||
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
|
||||
}
|
||||
if (str($this->publicPort)->isEmpty()) {
|
||||
$this->publicPort = null;
|
||||
}
|
||||
@@ -306,6 +314,17 @@ class General extends Component
|
||||
|
||||
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$this->server->generateCaCertificate();
|
||||
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SslHelper::generateSslCertificate(
|
||||
commonName: $existingCert->common_name,
|
||||
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
|
||||
|
||||
@@ -42,9 +42,9 @@ class General extends Component
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
public mixed $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
public mixed $publicPortTimeout = 3600;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
@@ -65,9 +65,11 @@ class General extends Component
|
||||
public function getListeners()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$teamId = Auth::user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
|
||||
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -82,9 +84,9 @@ class General extends Component
|
||||
'mysqlDatabase' => 'required',
|
||||
'mysqlConf' => 'nullable',
|
||||
'image' => 'required',
|
||||
'portsMappings' => 'nullable',
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
@@ -97,6 +99,7 @@ class General extends Component
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
ValidationPatterns::portMappingMessages(),
|
||||
[
|
||||
'name.required' => 'The Name field is required.',
|
||||
'mysqlRootPassword.required' => 'The Root Password field is required.',
|
||||
@@ -105,6 +108,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.',
|
||||
'publicPort.min' => 'The Public Port must be at least 1.',
|
||||
'publicPort.max' => 'The Public Port must not exceed 65535.',
|
||||
'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.',
|
||||
@@ -174,8 +179,8 @@ class General extends Component
|
||||
$this->database->image = $this->image;
|
||||
$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->public_port = $this->publicPort ?: null;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->enable_ssl = $this->enableSsl;
|
||||
@@ -230,6 +235,9 @@ class General extends Component
|
||||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
if ($this->portsMappings) {
|
||||
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
|
||||
}
|
||||
if (str($this->publicPort)->isEmpty()) {
|
||||
$this->publicPort = null;
|
||||
}
|
||||
@@ -311,6 +319,17 @@ class General extends Component
|
||||
|
||||
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$this->server->generateCaCertificate();
|
||||
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SslHelper::generateSslCertificate(
|
||||
commonName: $existingCert->common_name,
|
||||
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
|
||||
|
||||
@@ -46,9 +46,9 @@ class General extends Component
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
public mixed $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
public mixed $publicPortTimeout = 3600;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
@@ -73,9 +73,11 @@ class General extends Component
|
||||
public function getListeners()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$teamId = Auth::user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
|
||||
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
|
||||
'save_init_script',
|
||||
'delete_init_script',
|
||||
];
|
||||
@@ -94,9 +96,9 @@ class General extends Component
|
||||
'postgresConf' => 'nullable',
|
||||
'initScripts' => 'nullable',
|
||||
'image' => 'required',
|
||||
'portsMappings' => 'nullable',
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
@@ -109,6 +111,7 @@ class General extends Component
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
ValidationPatterns::portMappingMessages(),
|
||||
[
|
||||
'name.required' => 'The Name field is required.',
|
||||
'postgresUser.required' => 'The Postgres User field is required.',
|
||||
@@ -116,6 +119,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.',
|
||||
'publicPort.min' => 'The Public Port must be at least 1.',
|
||||
'publicPort.max' => 'The Public Port must not exceed 65535.',
|
||||
'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.',
|
||||
@@ -188,8 +193,8 @@ class General extends Component
|
||||
$this->database->image = $this->image;
|
||||
$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->public_port = $this->publicPort ?: null;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->enable_ssl = $this->enableSsl;
|
||||
@@ -273,6 +278,17 @@ class General extends Component
|
||||
|
||||
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$this->server->generateCaCertificate();
|
||||
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SslHelper::generateSslCertificate(
|
||||
commonName: $existingCert->common_name,
|
||||
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
|
||||
@@ -465,6 +481,9 @@ class General extends Component
|
||||
try {
|
||||
$this->authorize('update', $this->database);
|
||||
|
||||
if ($this->portsMappings) {
|
||||
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
|
||||
}
|
||||
if (str($this->publicPort)->isEmpty()) {
|
||||
$this->publicPort = null;
|
||||
}
|
||||
@@ -480,4 +499,10 @@ class General extends Component
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
$this->database->refresh();
|
||||
$this->syncData();
|
||||
}
|
||||
}
|
||||
@@ -34,9 +34,9 @@ class General extends Component
|
||||
|
||||
public ?bool $isPublic = null;
|
||||
|
||||
public ?int $publicPort = null;
|
||||
public mixed $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
public mixed $publicPortTimeout = 3600;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
@@ -61,9 +61,11 @@ class General extends Component
|
||||
public function getListeners()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$teamId = Auth::user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
|
||||
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
|
||||
'envsUpdated' => 'refresh',
|
||||
];
|
||||
}
|
||||
@@ -75,9 +77,9 @@ class General extends Component
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'redisConf' => 'nullable',
|
||||
'image' => 'required',
|
||||
'portsMappings' => 'nullable',
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
@@ -91,10 +93,13 @@ class General extends Component
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::combinedMessages(),
|
||||
ValidationPatterns::portMappingMessages(),
|
||||
[
|
||||
'name.required' => 'The Name field is required.',
|
||||
'image.required' => 'The Docker Image field is required.',
|
||||
'publicPort.integer' => 'The Public Port must be an integer.',
|
||||
'publicPort.min' => 'The Public Port must be at least 1.',
|
||||
'publicPort.max' => 'The Public Port must not exceed 65535.',
|
||||
'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.',
|
||||
@@ -157,8 +162,8 @@ class General extends Component
|
||||
$this->database->image = $this->image;
|
||||
$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->public_port = $this->publicPort ?: null;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->enable_ssl = $this->enableSsl;
|
||||
@@ -210,6 +215,9 @@ class General extends Component
|
||||
try {
|
||||
$this->authorize('manageEnvironment', $this->database);
|
||||
|
||||
if ($this->portsMappings) {
|
||||
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
|
||||
}
|
||||
$this->syncData(true);
|
||||
|
||||
if (version_compare($this->redisVersion, '6.0', '>=')) {
|
||||
@@ -291,9 +299,20 @@ class General extends Component
|
||||
|
||||
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
|
||||
if (! $caCert) {
|
||||
$this->server->generateCaCertificate();
|
||||
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
||||
}
|
||||
|
||||
if (! $caCert) {
|
||||
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SslHelper::generateSslCertificate(
|
||||
commonName: $existingCert->commonName,
|
||||
subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [],
|
||||
commonName: $existingCert->common_name,
|
||||
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
|
||||
resourceType: $existingCert->resource_type,
|
||||
resourceId: $existingCert->resource_id,
|
||||
serverId: $existingCert->server_id,
|
||||
|
||||
@@ -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()
|
||||
@@ -30,7 +30,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()) {
|
||||
|
||||
@@ -5,8 +5,6 @@ namespace App\Livewire\Project\New;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use Livewire\Component;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
@@ -31,7 +29,6 @@ class DockerCompose extends Component
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$server_id = $this->query['server_id'];
|
||||
try {
|
||||
$this->validate([
|
||||
'dockerComposeRaw' => 'required',
|
||||
@@ -41,23 +38,20 @@ 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();
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$destination_class = $destination->getMorphClass();
|
||||
|
||||
$service = Service::create([
|
||||
'docker_compose_raw' => $this->dockerComposeRaw,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => (int) $server_id,
|
||||
'server_id' => $destination->server_id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination_class,
|
||||
]);
|
||||
|
||||
@@ -4,8 +4,6 @@ namespace App\Livewire\Project\New;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Project;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Services\DockerImageParser;
|
||||
use Livewire\Component;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
@@ -111,18 +109,15 @@ class DockerImage extends Component
|
||||
$parser = new DockerImageParser;
|
||||
$parser->parse($dockerImage);
|
||||
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$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();
|
||||
|
||||
@@ -5,9 +5,8 @@ namespace App\Livewire\Project\New;
|
||||
use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\Project;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Livewire\Component;
|
||||
@@ -98,6 +97,8 @@ class GithubPrivateRepository extends Component
|
||||
public function loadRepositories($github_app_id)
|
||||
{
|
||||
$this->repositories = collect();
|
||||
$this->branches = collect();
|
||||
$this->total_branches_count = 0;
|
||||
$this->page = 1;
|
||||
$this->selected_github_app_id = $github_app_id;
|
||||
$this->github_app = GithubApp::where('id', $github_app_id)->first();
|
||||
@@ -168,25 +169,22 @@ class GithubPrivateRepository extends Component
|
||||
'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' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
'docker_compose_location' => ValidationPatterns::filePathRules(),
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first());
|
||||
}
|
||||
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$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),
|
||||
|
||||
@@ -7,10 +7,9 @@ use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
use Spatie\Url\Url;
|
||||
@@ -57,16 +56,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',
|
||||
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
];
|
||||
|
||||
protected function rules()
|
||||
{
|
||||
return [
|
||||
@@ -76,7 +65,7 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
'is_static' => 'required|boolean',
|
||||
'publish_directory' => 'nullable|string',
|
||||
'build_pack' => 'required|string',
|
||||
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
'docker_compose_location' => ValidationPatterns::filePathRules(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -139,13 +128,10 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
{
|
||||
$this->validate();
|
||||
try {
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$destination_class = $destination->getMorphClass();
|
||||
|
||||
@@ -154,8 +140,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(),
|
||||
|
||||
@@ -7,10 +7,9 @@ use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Carbon\Carbon;
|
||||
use Livewire\Component;
|
||||
use Spatie\Url\Url;
|
||||
@@ -33,8 +32,6 @@ class PublicGitRepository extends Component
|
||||
|
||||
public bool $isStatic = false;
|
||||
|
||||
public bool $checkCoolifyConfig = true;
|
||||
|
||||
public ?string $publish_directory = null;
|
||||
|
||||
// In case of docker compose
|
||||
@@ -63,16 +60,6 @@ class PublicGitRepository extends Component
|
||||
|
||||
public bool $new_compose_services = false;
|
||||
|
||||
protected $rules = [
|
||||
'repository_url' => ['required', 'string'],
|
||||
'port' => 'required|numeric',
|
||||
'isStatic' => 'required|boolean',
|
||||
'publish_directory' => 'nullable|string',
|
||||
'build_pack' => 'required|string',
|
||||
'base_directory' => 'nullable|string',
|
||||
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
];
|
||||
|
||||
protected function rules()
|
||||
{
|
||||
return [
|
||||
@@ -82,7 +69,7 @@ class PublicGitRepository extends Component
|
||||
'publish_directory' => 'nullable|string',
|
||||
'build_pack' => 'required|string',
|
||||
'base_directory' => 'nullable|string',
|
||||
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
'docker_compose_location' => ValidationPatterns::filePathRules(),
|
||||
'git_branch' => ['required', 'string', new ValidGitBranch],
|
||||
];
|
||||
}
|
||||
@@ -217,13 +204,8 @@ class PublicGitRepository extends Component
|
||||
|
||||
if ($this->repository_url_parsed->getSegment(3) === 'tree') {
|
||||
$path = str($this->repository_url_parsed->getPath())->trim('/');
|
||||
$this->git_branch = str($path)->after('tree/')->before('/')->value();
|
||||
$this->base_directory = str($path)->after($this->git_branch)->after('/')->value();
|
||||
if (filled($this->base_directory)) {
|
||||
$this->base_directory = '/'.$this->base_directory;
|
||||
} else {
|
||||
$this->base_directory = '/';
|
||||
}
|
||||
$this->git_branch = str($path)->after('tree/')->value();
|
||||
$this->base_directory = '/';
|
||||
} else {
|
||||
$this->git_branch = 'main';
|
||||
}
|
||||
@@ -243,10 +225,33 @@ class PublicGitRepository extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->git_source->getMorphClass() === \App\Models\GithubApp::class) {
|
||||
['rate_limit_remaining' => $this->rate_limit_remaining, 'rate_limit_reset' => $this->rate_limit_reset] = githubApi(source: $this->git_source, endpoint: "/repos/{$this->git_repository}/branches/{$this->git_branch}");
|
||||
$this->rate_limit_reset = Carbon::parse((int) $this->rate_limit_reset)->format('Y-M-d H:i:s');
|
||||
$this->branchFound = true;
|
||||
if ($this->git_source->getMorphClass() === GithubApp::class) {
|
||||
$originalBranch = $this->git_branch;
|
||||
$branchToTry = $originalBranch;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
$encodedBranch = urlencode($branchToTry);
|
||||
['rate_limit_remaining' => $this->rate_limit_remaining, 'rate_limit_reset' => $this->rate_limit_reset] = githubApi(source: $this->git_source, endpoint: "/repos/{$this->git_repository}/branches/{$encodedBranch}");
|
||||
$this->rate_limit_reset = Carbon::parse((int) $this->rate_limit_reset)->format('Y-M-d H:i:s');
|
||||
$this->git_branch = $branchToTry;
|
||||
|
||||
$remaining = str($originalBranch)->after($branchToTry)->trim('/')->value();
|
||||
$this->base_directory = filled($remaining) ? '/'.$remaining : '/';
|
||||
|
||||
$this->branchFound = true;
|
||||
|
||||
return;
|
||||
} catch (\Throwable $e) {
|
||||
if (str_contains($branchToTry, '/')) {
|
||||
$branchToTry = str($branchToTry)->beforeLast('/')->value();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,21 +280,18 @@ class PublicGitRepository extends Component
|
||||
throw new \RuntimeException('Invalid branch: '.$branchValidator->errors()->first('git_branch'));
|
||||
}
|
||||
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$project_uuid = $this->parameters['project_uuid'];
|
||||
$environment_uuid = $this->parameters['environment_uuid'];
|
||||
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$destination_class = $destination->getMorphClass();
|
||||
|
||||
$project = Project::where('uuid', $project_uuid)->first();
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', $environment_uuid)->first();
|
||||
$project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail();
|
||||
$environment = $project->environments()->where('uuid', $environment_uuid)->firstOrFail();
|
||||
|
||||
if ($this->build_pack === 'dockercompose' && isDev() && $this->new_compose_services) {
|
||||
$server = $destination->server;
|
||||
@@ -362,12 +364,6 @@ class PublicGitRepository extends Component
|
||||
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
|
||||
$application->fqdn = $fqdn;
|
||||
$application->save();
|
||||
if ($this->checkCoolifyConfig) {
|
||||
// $config = loadConfigFromGit($this->repository_url, $this->git_branch, $this->base_directory, $this->query['server_id'], auth()->user()->currentTeam()->id);
|
||||
// if ($config) {
|
||||
// $application->setConfig($config);
|
||||
// }
|
||||
}
|
||||
|
||||
return redirect()->route('project.application.configuration', [
|
||||
'application_uuid' => $application->uuid,
|
||||
|
||||
@@ -65,7 +65,7 @@ class Select extends Component
|
||||
$this->existingPostgresqlUrl = 'postgres://coolify:password@coolify-db:5432';
|
||||
}
|
||||
$projectUuid = data_get($this->parameters, 'project_uuid');
|
||||
$project = Project::whereUuid($projectUuid)->firstOrFail();
|
||||
$project = Project::ownedByCurrentTeam()->whereUuid($projectUuid)->firstOrFail();
|
||||
$this->environments = $project->environments;
|
||||
$this->selectedEnvironment = $this->environments->where('uuid', data_get($this->parameters, 'environment_uuid'))->firstOrFail()->name;
|
||||
|
||||
@@ -79,7 +79,7 @@ class Select extends Component
|
||||
$this->type = $queryType;
|
||||
$this->server_id = $queryServerId;
|
||||
$this->destination_uuid = $queryDestination;
|
||||
$this->server = Server::find($queryServerId);
|
||||
$this->server = Server::ownedByCurrentTeam()->find($queryServerId);
|
||||
$this->current_step = 'select-postgresql-type';
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -5,8 +5,6 @@ namespace App\Livewire\Project\New;
|
||||
use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\Project;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use Livewire\Component;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
@@ -35,18 +33,15 @@ CMD ["nginx", "-g", "daemon off;"]
|
||||
$this->validate([
|
||||
'dockerfile' => 'required',
|
||||
]);
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
|
||||
}
|
||||
if (! $destination) {
|
||||
throw new \Exception('Destination not found. What?!');
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
$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();
|
||||
|
||||
$port = get_port_from_dockerfile($this->dockerfile);
|
||||
if (! $port) {
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Livewire\Project\Resource;
|
||||
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use Livewire\Component;
|
||||
|
||||
class Create extends Component
|
||||
@@ -18,7 +17,6 @@ class Create extends Component
|
||||
|
||||
$type = str(request()->query('type'));
|
||||
$destination_uuid = request()->query('destination');
|
||||
$server_id = request()->query('server_id');
|
||||
$database_image = request()->query('database_image');
|
||||
|
||||
$project = currentTeam()->load(['projects'])->projects->where('uuid', request()->route('project_uuid'))->first();
|
||||
@@ -30,7 +28,11 @@ class Create extends Component
|
||||
if (! $environment) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
if (isset($type) && isset($destination_uuid) && isset($server_id)) {
|
||||
if (isset($type) && isset($destination_uuid)) {
|
||||
$destination = find_destination_for_current_team($destination_uuid);
|
||||
if (! $destination) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
$services = get_service_templates();
|
||||
|
||||
if (in_array($type, DATABASE_TYPES)) {
|
||||
@@ -44,23 +46,23 @@ class Create extends Component
|
||||
}
|
||||
$database = create_standalone_postgresql(
|
||||
environmentId: $environment->id,
|
||||
destinationUuid: $destination_uuid,
|
||||
destination: $destination,
|
||||
databaseImage: $database_image
|
||||
);
|
||||
} elseif ($type->value() === 'redis') {
|
||||
$database = create_standalone_redis($environment->id, $destination_uuid);
|
||||
$database = create_standalone_redis($environment->id, $destination);
|
||||
} elseif ($type->value() === 'mongodb') {
|
||||
$database = create_standalone_mongodb($environment->id, $destination_uuid);
|
||||
$database = create_standalone_mongodb($environment->id, $destination);
|
||||
} elseif ($type->value() === 'mysql') {
|
||||
$database = create_standalone_mysql($environment->id, $destination_uuid);
|
||||
$database = create_standalone_mysql($environment->id, $destination);
|
||||
} elseif ($type->value() === 'mariadb') {
|
||||
$database = create_standalone_mariadb($environment->id, $destination_uuid);
|
||||
$database = create_standalone_mariadb($environment->id, $destination);
|
||||
} elseif ($type->value() === 'keydb') {
|
||||
$database = create_standalone_keydb($environment->id, $destination_uuid);
|
||||
$database = create_standalone_keydb($environment->id, $destination);
|
||||
} elseif ($type->value() === 'dragonfly') {
|
||||
$database = create_standalone_dragonfly($environment->id, $destination_uuid);
|
||||
$database = create_standalone_dragonfly($environment->id, $destination);
|
||||
} elseif ($type->value() === 'clickhouse') {
|
||||
$database = create_standalone_clickhouse($environment->id, $destination_uuid);
|
||||
$database = create_standalone_clickhouse($environment->id, $destination);
|
||||
}
|
||||
|
||||
return redirect()->route('project.database.configuration', [
|
||||
@@ -69,7 +71,7 @@ class Create extends Component
|
||||
'database_uuid' => $database->uuid,
|
||||
]);
|
||||
}
|
||||
if ($type->startsWith('one-click-service-') && ! is_null((int) $server_id)) {
|
||||
if ($type->startsWith('one-click-service-')) {
|
||||
$oneClickServiceName = $type->after('one-click-service-')->value();
|
||||
$oneClickService = data_get($services, "$oneClickServiceName.compose");
|
||||
$oneClickDotEnvs = data_get($services, "$oneClickServiceName.envs", null);
|
||||
@@ -79,12 +81,11 @@ class Create extends Component
|
||||
});
|
||||
}
|
||||
if ($oneClickService) {
|
||||
$destination = StandaloneDocker::whereUuid($destination_uuid)->first();
|
||||
$service_payload = [
|
||||
'docker_compose_raw' => base64_decode($oneClickService),
|
||||
'environment_id' => $environment->id,
|
||||
'service_type' => $oneClickServiceName,
|
||||
'server_id' => (int) $server_id,
|
||||
'server_id' => $destination->server_id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
];
|
||||
|
||||
@@ -13,33 +13,33 @@ class Index extends Component
|
||||
|
||||
public Environment $environment;
|
||||
|
||||
public Collection $applications;
|
||||
|
||||
public Collection $postgresqls;
|
||||
|
||||
public Collection $redis;
|
||||
|
||||
public Collection $mongodbs;
|
||||
|
||||
public Collection $mysqls;
|
||||
|
||||
public Collection $mariadbs;
|
||||
|
||||
public Collection $keydbs;
|
||||
|
||||
public Collection $dragonflies;
|
||||
|
||||
public Collection $clickhouses;
|
||||
|
||||
public Collection $services;
|
||||
|
||||
public Collection $allProjects;
|
||||
|
||||
public Collection $allEnvironments;
|
||||
|
||||
public array $parameters;
|
||||
|
||||
public function mount()
|
||||
protected Collection $applications;
|
||||
|
||||
protected Collection $postgresqls;
|
||||
|
||||
protected Collection $redis;
|
||||
|
||||
protected Collection $mongodbs;
|
||||
|
||||
protected Collection $mysqls;
|
||||
|
||||
protected Collection $mariadbs;
|
||||
|
||||
protected Collection $keydbs;
|
||||
|
||||
protected Collection $dragonflies;
|
||||
|
||||
protected Collection $clickhouses;
|
||||
|
||||
protected Collection $services;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->applications = $this->postgresqls = $this->redis = $this->mongodbs = $this->mysqls = $this->mariadbs = $this->keydbs = $this->dragonflies = $this->clickhouses = $this->services = collect();
|
||||
$this->parameters = get_route_parameters();
|
||||
@@ -55,31 +55,23 @@ class Index extends Component
|
||||
|
||||
$this->project = $project;
|
||||
|
||||
// Load projects and environments for breadcrumb navigation (avoids inline queries in view)
|
||||
// Load projects and environments for breadcrumb navigation
|
||||
$this->allProjects = Project::ownedByCurrentTeamCached();
|
||||
$this->allEnvironments = $project->environments()
|
||||
->select('id', 'uuid', 'name', 'project_id')
|
||||
->with([
|
||||
'applications.additional_servers',
|
||||
'applications.destination.server',
|
||||
'services',
|
||||
'services.destination.server',
|
||||
'postgresqls',
|
||||
'postgresqls.destination.server',
|
||||
'redis',
|
||||
'redis.destination.server',
|
||||
'mongodbs',
|
||||
'mongodbs.destination.server',
|
||||
'mysqls',
|
||||
'mysqls.destination.server',
|
||||
'mariadbs',
|
||||
'mariadbs.destination.server',
|
||||
'keydbs',
|
||||
'keydbs.destination.server',
|
||||
'dragonflies',
|
||||
'dragonflies.destination.server',
|
||||
'clickhouses',
|
||||
'clickhouses.destination.server',
|
||||
])->get();
|
||||
'applications:id,uuid,name,environment_id',
|
||||
'services:id,uuid,name,environment_id',
|
||||
'postgresqls:id,uuid,name,environment_id',
|
||||
'redis:id,uuid,name,environment_id',
|
||||
'mongodbs:id,uuid,name,environment_id',
|
||||
'mysqls:id,uuid,name,environment_id',
|
||||
'mariadbs:id,uuid,name,environment_id',
|
||||
'keydbs:id,uuid,name,environment_id',
|
||||
'dragonflies:id,uuid,name,environment_id',
|
||||
'clickhouses:id,uuid,name,environment_id',
|
||||
])
|
||||
->get();
|
||||
|
||||
$this->environment = $environment->loadCount([
|
||||
'applications',
|
||||
@@ -94,11 +86,9 @@ class Index extends Component
|
||||
'services',
|
||||
]);
|
||||
|
||||
// Eager load all relationships for applications including nested ones
|
||||
// Eager load relationships for applications
|
||||
$this->applications = $this->environment->applications()->with([
|
||||
'tags',
|
||||
'additional_servers.settings',
|
||||
'additional_networks',
|
||||
'destination.server.settings',
|
||||
'settings',
|
||||
])->get()->sortBy('name');
|
||||
@@ -160,6 +150,49 @@ class Index extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.resource.index');
|
||||
return view('livewire.project.resource.index', [
|
||||
'applications' => $this->applications,
|
||||
'postgresqls' => $this->postgresqls,
|
||||
'redis' => $this->redis,
|
||||
'mongodbs' => $this->mongodbs,
|
||||
'mysqls' => $this->mysqls,
|
||||
'mariadbs' => $this->mariadbs,
|
||||
'keydbs' => $this->keydbs,
|
||||
'dragonflies' => $this->dragonflies,
|
||||
'clickhouses' => $this->clickhouses,
|
||||
'services' => $this->services,
|
||||
'applicationsJs' => $this->toSearchableArray($this->applications),
|
||||
'postgresqlsJs' => $this->toSearchableArray($this->postgresqls),
|
||||
'redisJs' => $this->toSearchableArray($this->redis),
|
||||
'mongodbsJs' => $this->toSearchableArray($this->mongodbs),
|
||||
'mysqlsJs' => $this->toSearchableArray($this->mysqls),
|
||||
'mariadbsJs' => $this->toSearchableArray($this->mariadbs),
|
||||
'keydbsJs' => $this->toSearchableArray($this->keydbs),
|
||||
'dragonfliesJs' => $this->toSearchableArray($this->dragonflies),
|
||||
'clickhousesJs' => $this->toSearchableArray($this->clickhouses),
|
||||
'servicesJs' => $this->toSearchableArray($this->services),
|
||||
]);
|
||||
}
|
||||
|
||||
private function toSearchableArray(Collection $items): array
|
||||
{
|
||||
return $items->map(fn ($item) => [
|
||||
'uuid' => $item->uuid,
|
||||
'name' => $item->name,
|
||||
'fqdn' => $item->fqdn ?? null,
|
||||
'description' => $item->description ?? null,
|
||||
'status' => $item->status ?? '',
|
||||
'server_status' => $item->server_status ?? null,
|
||||
'hrefLink' => $item->hrefLink ?? '',
|
||||
'destination' => [
|
||||
'server' => [
|
||||
'name' => $item->destination?->server?->name ?? 'Unknown',
|
||||
],
|
||||
],
|
||||
'tags' => $item->tags->map(fn ($tag) => [
|
||||
'id' => $tag->id,
|
||||
'name' => $tag->name,
|
||||
])->values()->toArray(),
|
||||
])->values()->toArray();
|
||||
}
|
||||
}
|
||||
Loaded 100 of 685 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user