diff --git a/.env.development.example b/.env.development.example index b0b15f324..594b89201 100644 --- a/.env.development.example +++ b/.env.development.example @@ -24,6 +24,10 @@ RAY_ENABLED=false # Enable Laravel Telescope for debugging TELESCOPE_ENABLED=false +# Enable Laravel Nightwatch monitoring +NIGHTWATCH_ENABLED=false +NIGHTWATCH_TOKEN= + # Selenium Driver URL for Dusk DUSK_DRIVER_URL=http://selenium:4444 diff --git a/README.md b/README.md index b7aefe16a..a5aa69343 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ ### Big Sponsors * [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions * [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner * [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform -* [Brand.dev](https://brand.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain +* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain * [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale * [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half * [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor @@ -90,6 +90,7 @@ ### Big Sponsors * [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers * [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity * [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity +* [PetroSky Cloud](https://petrosky.io?ref=coolify.io) - Open source cloud deployment solutions * [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang * [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting * [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index de44b476f..159f12252 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -2,10 +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 { @@ -24,6 +26,17 @@ public function handle(Server $server, bool $forceRegenerate = false): string // 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); @@ -55,6 +68,29 @@ public function handle(Server $server, bool $forceRegenerate = false): string 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. diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 31e582c9b..2e08ec6ad 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -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: documentation.'); @@ -118,7 +115,7 @@ public function handle(Server $server) 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 @@ private function getDebianDockerInstallCommand(): string 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 @@ private function getRhelDockerInstallCommand(): string 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 @@ private function getSuseDockerInstallCommand(): string 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 @@ private function getArchDockerInstallCommand(): string 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'; } } diff --git a/app/Console/Commands/Nightwatch.php b/app/Console/Commands/Nightwatch.php new file mode 100644 index 000000000..40fd86a81 --- /dev/null +++ b/app/Console/Commands/Nightwatch.php @@ -0,0 +1,22 @@ +info('Nightwatch is enabled on this server.'); + $this->call('nightwatch:agent'); + } + + exit(0); + } +} diff --git a/app/Console/Commands/ScheduledJobDiagnostics.php b/app/Console/Commands/ScheduledJobDiagnostics.php new file mode 100644 index 000000000..77881284c --- /dev/null +++ b/app/Console/Commands/ScheduledJobDiagnostics.php @@ -0,0 +1,255 @@ +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 ?? '', + $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 ?? '', + $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 ?? '', + $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 ?? '', + $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(); + } +} diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 3444f9f14..66f6a1ef8 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -11,6 +11,8 @@ use App\Models\Application; use App\Models\EnvironmentVariable; use App\Models\GithubApp; +use App\Models\LocalFileVolume; +use App\Models\LocalPersistentVolume; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; @@ -4026,9 +4028,10 @@ public function storages(Request $request): JsonResponse mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['id', 'type'], + required: ['type'], properties: [ - 'id' => ['type' => 'integer', 'description' => 'The ID of the storage.'], + '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).'], @@ -4078,7 +4081,7 @@ public function update_storage(Request $request): JsonResponse return $return; } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ @@ -4089,7 +4092,8 @@ public function update_storage(Request $request): JsonResponse $this->authorize('update', $application); $validator = customApiValidator($request->all(), [ - 'id' => 'required|integer', + 'uuid' => 'string', + 'id' => 'integer', 'type' => 'required|string|in:persistent,file', 'is_preview_suffix_enabled' => 'boolean', 'name' => 'string', @@ -4098,7 +4102,7 @@ public function update_storage(Request $request): JsonResponse 'content' => 'string|nullable', ]); - $allAllowedFields = ['id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content']; + $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(); @@ -4114,10 +4118,23 @@ public function update_storage(Request $request): JsonResponse ], 422); } + $storageUuid = $request->input('uuid'); + $storageId = $request->input('id'); + + if (! $storageUuid && ! $storageId) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['uuid' => 'Either uuid or id is required.'], + ], 422); + } + + $lookupField = $storageUuid ? 'uuid' : 'id'; + $lookupValue = $storageUuid ?? $storageId; + if ($request->type === 'persistent') { - $storage = $application->persistentStorages->where('id', $request->id)->first(); + $storage = $application->persistentStorages->where($lookupField, $lookupValue)->first(); } else { - $storage = $application->fileStorages->where('id', $request->id)->first(); + $storage = $application->fileStorages->where($lookupField, $lookupValue)->first(); } if (! $storage) { @@ -4183,4 +4200,254 @@ public function update_storage(Request $request): JsonResponse return response()->json($storage); } + + #[OA\Post( + summary: 'Create Storage', + description: 'Create a persistent storage or file storage for an application.', + path: '/applications/{uuid}/storages', + operationId: 'create-storage-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['type', 'mount_path'], + properties: [ + 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'], + 'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'], + 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], + 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], + 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], + 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'], + ], + additionalProperties: false, + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'Storage created.', + content: new OA\JsonContent(type: 'object'), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('update', $application); + + $validator = customApiValidator($request->all(), [ + 'type' => 'required|string|in:persistent,file', + 'name' => 'string', + 'mount_path' => 'required|string', + 'host_path' => 'string|nullable', + 'content' => 'string|nullable', + 'is_directory' => 'boolean', + 'fs_path' => 'string', + ]); + + $allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path']; + $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + if ($request->type === 'persistent') { + if (! $request->name) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['name' => 'The name field is required for persistent storages.'], + ], 422); + } + + $typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all())); + if (! empty($typeSpecificInvalidFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($typeSpecificInvalidFields) + ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'persistent'."]), + ], 422); + } + + $storage = LocalPersistentVolume::create([ + 'name' => $application->uuid.'-'.$request->name, + 'mount_path' => $request->mount_path, + 'host_path' => $request->host_path, + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + + return response()->json($storage, 201); + } + + // File storage + $typeSpecificInvalidFields = array_intersect(['name', 'host_path'], array_keys($request->all())); + if (! empty($typeSpecificInvalidFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($typeSpecificInvalidFields) + ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'file'."]), + ], 422); + } + + $isDirectory = $request->boolean('is_directory', false); + + if ($isDirectory) { + if (! $request->fs_path) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'], + ], 422); + } + + $fsPath = str($request->fs_path)->trim()->start('/')->value(); + $mountPath = str($request->mount_path)->trim()->start('/')->value(); + + validateShellSafePath($fsPath, 'storage source path'); + validateShellSafePath($mountPath, 'storage destination path'); + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'is_directory' => true, + 'resource_id' => $application->id, + 'resource_type' => get_class($application), + ]); + } else { + $mountPath = str($request->mount_path)->trim()->start('/')->value(); + $fsPath = application_configuration_dir().'/'.$application->uuid.$mountPath; + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'content' => $request->content, + 'is_directory' => false, + 'resource_id' => $application->id, + 'resource_type' => get_class($application), + ]); + } + + return response()->json($storage, 201); + } + + #[OA\Delete( + summary: 'Delete Storage', + description: 'Delete a persistent storage or file storage by application UUID.', + path: '/applications/{uuid}/storages/{storage_uuid}', + operationId: 'delete-storage-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'storage_uuid', + in: 'path', + description: 'UUID of the storage.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent( + properties: [new OA\Property(property: 'message', type: 'string')], + )), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function delete_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('update', $application); + + $storageUuid = $request->route('storage_uuid'); + + $storage = $application->persistentStorages->where('uuid', $storageUuid)->first(); + if (! $storage) { + $storage = $application->fileStorages->where('uuid', $storageUuid)->first(); + } + + if (! $storage) { + return response()->json(['message' => 'Storage not found.'], 404); + } + + if ($storage->shouldBeReadOnlyInUI()) { + return response()->json([ + 'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.', + ], 422); + } + + if ($storage instanceof LocalFileVolume) { + $storage->deleteStorageOnServer(); + } + + $storage->delete(); + + return response()->json(['message' => 'Storage deleted.']); + } } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 6ad18d872..700055fcc 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -12,11 +12,14 @@ use App\Jobs\DatabaseBackupJob; use App\Jobs\DeleteResourceJob; use App\Models\EnvironmentVariable; +use App\Models\LocalFileVolume; +use App\Models\LocalPersistentVolume; use App\Models\Project; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; use App\Models\StandalonePostgresql; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; @@ -3298,4 +3301,520 @@ public function delete_env_by_uuid(Request $request) return response()->json(['message' => 'Environment variable deleted.']); } + + #[OA\Get( + summary: 'List Storages', + description: 'List all persistent storages and file storages by database UUID.', + path: '/databases/{uuid}/storages', + operationId: 'list-storages-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'All storages by database 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(); + } + + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('view', $database); + + $persistentStorages = $database->persistentStorages->sortBy('id')->values(); + $fileStorages = $database->fileStorages->sortBy('id')->values(); + + return response()->json([ + 'persistent_storages' => $persistentStorages, + 'file_storages' => $fileStorages, + ]); + } + + #[OA\Post( + summary: 'Create Storage', + description: 'Create a persistent storage or file storage for a database.', + path: '/databases/{uuid}/storages', + operationId: 'create-storage-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['type', 'mount_path'], + properties: [ + 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'], + 'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'], + 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], + 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], + 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], + 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'], + ], + additionalProperties: false, + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'Storage created.', + content: new OA\JsonContent(type: 'object'), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + $validator = customApiValidator($request->all(), [ + 'type' => 'required|string|in:persistent,file', + 'name' => 'string', + 'mount_path' => 'required|string', + 'host_path' => 'string|nullable', + 'content' => 'string|nullable', + 'is_directory' => 'boolean', + 'fs_path' => 'string', + ]); + + $allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path']; + $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + if ($request->type === 'persistent') { + if (! $request->name) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['name' => 'The name field is required for persistent storages.'], + ], 422); + } + + $typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all())); + if (! empty($typeSpecificInvalidFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($typeSpecificInvalidFields) + ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'persistent'."]), + ], 422); + } + + $storage = LocalPersistentVolume::create([ + 'name' => $database->uuid.'-'.$request->name, + 'mount_path' => $request->mount_path, + 'host_path' => $request->host_path, + 'resource_id' => $database->id, + 'resource_type' => $database->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' => $database->id, + 'resource_type' => get_class($database), + ]); + } else { + $mountPath = str($request->mount_path)->trim()->start('/')->value(); + $fsPath = database_configuration_dir().'/'.$database->uuid.$mountPath; + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'content' => $request->content, + 'is_directory' => false, + 'resource_id' => $database->id, + 'resource_type' => get_class($database), + ]); + } + + return response()->json($storage, 201); + } + + #[OA\Patch( + summary: 'Update Storage', + description: 'Update a persistent storage or file storage by database UUID.', + path: '/databases/{uuid}/storages', + operationId: 'update-storage-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + 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 \Illuminate\Http\JsonResponse) { + return $return; + } + + $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + $validator = customApiValidator($request->all(), [ + 'uuid' => 'string', + 'id' => 'integer', + 'type' => 'required|string|in:persistent,file', + 'is_preview_suffix_enabled' => 'boolean', + 'name' => 'string', + 'mount_path' => 'string', + 'host_path' => 'string|nullable', + 'content' => 'string|nullable', + ]); + + $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content']; + $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $storageUuid = $request->input('uuid'); + $storageId = $request->input('id'); + + if (! $storageUuid && ! $storageId) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['uuid' => 'Either uuid or id is required.'], + ], 422); + } + + $lookupField = $storageUuid ? 'uuid' : 'id'; + $lookupValue = $storageUuid ?? $storageId; + + if ($request->type === 'persistent') { + $storage = $database->persistentStorages->where($lookupField, $lookupValue)->first(); + } else { + $storage = $database->fileStorages->where($lookupField, $lookupValue)->first(); + } + + if (! $storage) { + return response()->json([ + 'message' => 'Storage not found.', + ], 404); + } + + $isReadOnly = $storage->shouldBeReadOnlyInUI(); + $editableOnlyFields = ['name', 'mount_path', 'host_path', 'content']; + $requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all())); + + if ($isReadOnly && ! empty($requestedEditableFields)) { + return response()->json([ + 'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.', + 'read_only_fields' => array_values($requestedEditableFields), + ], 422); + } + + // Reject fields that don't apply to the given storage type + if (! $isReadOnly) { + $typeSpecificInvalidFields = $request->type === 'persistent' + ? array_intersect(['content'], array_keys($request->all())) + : array_intersect(['name', 'host_path'], array_keys($request->all())); + + if (! empty($typeSpecificInvalidFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($typeSpecificInvalidFields) + ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]), + ], 422); + } + } + + // Always allowed + if ($request->has('is_preview_suffix_enabled')) { + $storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled; + } + + // Only for editable storages + if (! $isReadOnly) { + if ($request->type === 'persistent') { + if ($request->has('name')) { + $storage->name = $request->name; + } + if ($request->has('mount_path')) { + $storage->mount_path = $request->mount_path; + } + if ($request->has('host_path')) { + $storage->host_path = $request->host_path; + } + } else { + if ($request->has('mount_path')) { + $storage->mount_path = $request->mount_path; + } + if ($request->has('content')) { + $storage->content = $request->content; + } + } + } + + $storage->save(); + + return response()->json($storage); + } + + #[OA\Delete( + summary: 'Delete Storage', + description: 'Delete a persistent storage or file storage by database UUID.', + path: '/databases/{uuid}/storages/{storage_uuid}', + operationId: 'delete-storage-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + 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(); + } + + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + $storageUuid = $request->route('storage_uuid'); + + $storage = $database->persistentStorages->where('uuid', $storageUuid)->first(); + if (! $storage) { + $storage = $database->fileStorages->where('uuid', $storageUuid)->first(); + } + + if (! $storage) { + return response()->json(['message' => 'Storage not found.'], 404); + } + + if ($storage->shouldBeReadOnlyInUI()) { + return response()->json([ + 'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.', + ], 422); + } + + if ($storage instanceof LocalFileVolume) { + $storage->deleteStorageOnServer(); + } + + $storage->delete(); + + return response()->json(['message' => 'Storage deleted.']); + } } diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 2645c2df1..ed91b4475 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -586,7 +586,8 @@ public function createServer(Request $request) } // 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); } diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 4caee26dd..ca565ece0 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -8,9 +8,12 @@ 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 Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; @@ -1849,4 +1852,606 @@ public function action_restart(Request $request) 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', + '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(); + $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', + '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.']); + } } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index e5a5b746e..fe49369ea 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -55,6 +55,9 @@ public function manual(Request $request) $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $author_association = data_get($payload, 'pull_request.author_association'); } + if (! in_array($x_github_event, ['push', 'pull_request'])) { + return response("Nothing to do. Event '$x_github_event' is not supported."); + } if (! $branch) { return response('Nothing to do. No branch found in the request.'); } @@ -246,6 +249,9 @@ public function normal(Request $request) $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.'); } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index e30af5cc7..9d927d10c 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2334,13 +2334,13 @@ private function nixpacks_build_cmd() $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}"; @@ -2353,13 +2353,15 @@ private function generate_nixpacks_env_variables() if ($this->pull_request_id === 0) { foreach ($this->application->nixpacks_environment_variables as $env) { if (! is_null($env->real_value) && $env->real_value !== '') { - $this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}"); + $value = ($env->is_literal || $env->is_multiline) ? trim($env->real_value, "'") : $env->real_value; + $this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}")); } } } else { foreach ($this->application->nixpacks_environment_variables_preview as $env) { if (! is_null($env->real_value) && $env->real_value !== '') { - $this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}"); + $value = ($env->is_literal || $env->is_multiline) ? trim($env->real_value, "'") : $env->real_value; + $this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}")); } } } @@ -2369,7 +2371,7 @@ private function generate_nixpacks_env_variables() $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}")); } }); diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index a8a3cb159..16f3d88ad 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -46,14 +46,20 @@ public function __construct( 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) { diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index ebcd229ed..71829ea41 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -6,7 +6,6 @@ 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 @@ private function processScheduledBackups(): void $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 @@ private function processScheduledTasks(): void $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 @@ private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string 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 - 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 @@ private function processDockerCleanups(): void } // Use the frozen execution time for consistent evaluation - if ($this->shouldRunNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}")) { + if (shouldRunCronNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}", $this->executionTime)) { DockerCleanupJob::dispatch( $server, false, diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index d56ff0a8c..3f748f0ca 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -5,7 +5,6 @@ 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; @@ -14,7 +13,6 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue @@ -81,7 +79,7 @@ private function getServers(): Collection private function dispatchConnectionChecks(Collection $servers): void { - if ($this->shouldRunNow($this->checkFrequency, dedupKey: 'server-connection-checks')) { + 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 @@ -130,13 +128,13 @@ private function processServerTasks(Server $server): void if ($sentinelOutOfSync) { // Dispatch ServerCheckJob if Sentinel is out of sync - if ($this->shouldRunNow($this->checkFrequency, $serverTimezone, "server-check:{$server->id}")) { + if (shouldRunCronNow($this->checkFrequency, $serverTimezone, "server-check:{$server->id}", $this->executionTime)) { ServerCheckJob::dispatch($server); } } $isSentinelEnabled = $server->isSentinelEnabled(); - $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}"); + $shouldRestartSentinel = $isSentinelEnabled && shouldRunCronNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}", $this->executionTime); // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) if ($shouldRestartSentinel) { @@ -150,7 +148,7 @@ private function processServerTasks(Server $server): void if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) { $serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency]; } - $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone, "server-storage-check:{$server->id}"); + $shouldRunStorageCheck = shouldRunCronNow($serverDiskUsageCheckFrequency, $serverTimezone, "server-storage-check:{$server->id}", $this->executionTime); if ($shouldRunStorageCheck) { ServerStorageCheckJob::dispatch($server); @@ -158,7 +156,7 @@ private function processServerTasks(Server $server): void } // Dispatch ServerPatchCheckJob if due (weekly) - $shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone, "server-patch-check:{$server->id}"); + $shouldRunPatchCheck = shouldRunCronNow('0 0 * * 0', $serverTimezone, "server-patch-check:{$server->id}", $this->executionTime); if ($shouldRunPatchCheck) { // Weekly on Sunday at midnight ServerPatchCheckJob::dispatch($server); @@ -167,45 +165,4 @@ private function processServerTasks(Server $server): void // Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates. // Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob. } - - /** - * 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. - */ - private function shouldRunNow(string $frequency, ?string $timezone = null, ?string $dedupKey = null): bool - { - $cron = new CronExpression($frequency); - - // Use the frozen execution time, not the current time - $baseTime = $this->executionTime ?? Carbon::now(); - $executionTime = $baseTime->copy()->setTimezone($timezone ?? config('app.timezone')); - - if ($dedupKey === null) { - return $cron->isDue($executionTime); - } - - $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true)); - - $lastDispatched = Cache::get($dedupKey); - - if ($lastDispatched === null) { - $isDue = $cron->isDue($executionTime); - if ($isDue) { - Cache::put($dedupKey, $executionTime->toIso8601String(), 86400); - } - - return $isDue; - } - - if ($previousDue->gt(Carbon::parse($lastDispatched))) { - Cache::put($dedupKey, $executionTime->toIso8601String(), 86400); - - return true; - } - - return false; - } } diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index f5d52f29c..3485ffe32 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -73,25 +73,15 @@ public function handle(): void // 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'); @@ -227,18 +217,14 @@ public function handle(): void // 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'); @@ -254,20 +240,19 @@ public function handle(): void $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'); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 2a18be13c..c567d96aa 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -98,6 +98,9 @@ public function getResourceProperty() public function refresh() { + if (! $this->env->exists || ! $this->env->fresh()) { + return; + } $this->syncData(); $this->checkEnvs(); } diff --git a/app/Livewire/Subscription/PricingPlans.php b/app/Livewire/Subscription/PricingPlans.php index 6b2d3fb36..6e1b85404 100644 --- a/app/Livewire/Subscription/PricingPlans.php +++ b/app/Livewire/Subscription/PricingPlans.php @@ -11,6 +11,12 @@ class PricingPlans extends Component { public function subscribeStripe($type) { + if (currentTeam()->subscription?->stripe_invoice_paid) { + $this->dispatch('error', 'Team already has an active subscription.'); + + return; + } + Stripe::setApiKey(config('subscription.stripe_api_key')); $priceId = match ($type) { diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index 1721f4afe..9d539f8ec 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -3,10 +3,9 @@ namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; -use Illuminate\Database\Eloquent\Model; use Symfony\Component\Yaml\Yaml; -class LocalPersistentVolume extends Model +class LocalPersistentVolume extends BaseModel { protected $guarded = []; diff --git a/app/Models/Server.php b/app/Models/Server.php index 527c744a5..ce877bd20 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1471,6 +1471,9 @@ public function changeProxy(string $proxyType, bool $async = true) if ($validProxyTypes->contains(str($proxyType)->lower())) { $this->proxy->set('type', str($proxyType)->upper()); $this->proxy->set('status', 'exited'); + $this->proxy->set('last_saved_proxy_configuration', null); + $this->proxy->set('last_saved_settings', null); + $this->proxy->set('last_applied_settings', null); $this->save(); if ($this->proxySet()) { if ($async) { diff --git a/app/Models/Team.php b/app/Models/Team.php index 10b22b4e1..5a7b377b6 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -89,10 +89,13 @@ protected static function booted() }); } - public static function serverLimitReached() + public static function serverLimitReached(?Team $team = null) { - $serverLimit = Team::serverLimit(); - $team = currentTeam(); + $team = $team ?? currentTeam(); + if (! $team) { + return true; + } + $serverLimit = Team::serverLimit($team); $servers = $team->servers->count(); return $servers >= $serverLimit; @@ -109,19 +112,23 @@ public function subscriptionPastOverDue() public function serverOverflow() { - if ($this->serverLimit() < $this->servers->count()) { + if (Team::serverLimit($this) < $this->servers->count()) { return true; } return false; } - public static function serverLimit() + public static function serverLimit(?Team $team = null) { - if (currentTeam()->id === 0 && isDev()) { + $team = $team ?? currentTeam(); + if (! $team) { + return 0; + } + if ($team->id === 0 && isDev()) { return 9999999; } - $team = Team::find(currentTeam()->id); + $team = Team::find($team->id); if (! $team) { return 0; } diff --git a/app/Rules/ValidHostname.php b/app/Rules/ValidHostname.php index b6b2b8d32..89b68663b 100644 --- a/app/Rules/ValidHostname.php +++ b/app/Rules/ValidHostname.php @@ -62,12 +62,15 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // Ignore errors when facades are not available (e.g., in unit tests) } - $fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); + $fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); return; } } + // Normalize to lowercase for validation (RFC 1123 hostnames are case-insensitive) + $hostname = strtolower($hostname); + // Additional validation: hostname should not start or end with a dot if (str_starts_with($hostname, '.') || str_ends_with($hostname, '.')) { $fail('The :attribute cannot start or end with a dot.'); @@ -100,9 +103,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - // Check if label contains only valid characters (lowercase letters, digits, hyphens) + // Check if label contains only valid characters (letters, digits, hyphens) if (! preg_match('/^[a-z0-9-]+$/', $label)) { - $fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); + $fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); return; } diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index fdf2b12a6..7b8251729 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -10,7 +10,7 @@ class ValidationPatterns /** * Pattern for names excluding all dangerous characters */ - public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&]+$/u'; + public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+$/u'; /** * Pattern for descriptions excluding all dangerous characters with some additional allowed characters @@ -96,7 +96,7 @@ public static function descriptionRules(bool $required = false, int $maxLength = public static function nameMessages(): array { return [ - 'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &', + 'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ & ( ) # , : +', 'name.min' => 'The name must be at least :min characters.', 'name.max' => 'The name may not be greater than :max characters.', ]; diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 7b74392cf..5905ed3c1 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -137,6 +137,11 @@ function checkMinimumDockerEngineVersion($dockerVersion) return $dockerVersion; } +function escapeShellValue(string $value): string +{ + return "'".str_replace("'", "'\\''", $value)."'"; +} + function executeInDocker(string $containerId, string $command) { $escapedCommand = str_replace("'", "'\\''", $command); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index cd4928d63..4ca693fcb 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -990,16 +990,17 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } if ($key->value() === $parsedValue->value()) { // Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL}) - // Use firstOrCreate to avoid overwriting user-saved values on redeploy - $envVar = $resource->environment_variables()->firstOrCreate([ + // Ensure the variable exists in DB for .env generation and UI display + $resource->environment_variables()->firstOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ 'is_preview' => false, ]); - // Add the variable to the environment using the saved DB value - $environment[$key->value()] = $envVar->value; + // Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time. + // Do NOT replace with DB value: if user updates env var without re-parsing compose, + // a stale resolved value in environment: would override the correct .env value. } else { if ($value->startsWith('$')) { $isRequired = false; @@ -2341,8 +2342,8 @@ function serviceParser(Service $resource): Collection } if ($key->value() === $parsedValue->value()) { // Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL}) - // Use firstOrCreate to avoid overwriting user-saved values on redeploy - $envVar = $resource->environment_variables()->firstOrCreate([ + // Ensure the variable exists in DB for .env generation and UI display + $resource->environment_variables()->firstOrCreate([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, @@ -2350,8 +2351,9 @@ function serviceParser(Service $resource): Collection 'is_preview' => false, 'comment' => $envComments[$originalKey] ?? null, ]); - // Add the variable to the environment using the saved DB value - $environment[$key->value()] = $envVar->value; + // Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time. + // Do NOT replace with DB value: if user updates env var without re-parsing compose, + // a stale resolved value in environment: would override the correct .env value. } else { if ($value->startsWith('$')) { $isRequired = false; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index e81d2a467..a8cffcaff 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -339,7 +339,18 @@ function generate_application_name(string $git_repository, string $git_branch, ? $cuid = new Cuid2; } - return Str::kebab("$git_repository:$git_branch-$cuid"); + $repo_name = str_contains($git_repository, '/') ? last(explode('/', $git_repository)) : $git_repository; + + $name = Str::kebab("$repo_name:$git_branch-$cuid"); + + // Strip characters not allowed by NAME_PATTERN + $name = preg_replace('/[^\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+/u', '', $name); + + if (empty($name) || mb_strlen($name) < 3) { + return generate_random_name($cuid); + } + + return $name; } /** @@ -466,6 +477,36 @@ function validate_cron_expression($expression_to_validate): bool return $isValid; } +/** + * Determine if a cron schedule should run now, with deduplication. + * + * Uses getPreviousRunDate() + last-dispatch tracking to be resilient to queue delays. + * Even if the job runs minutes late, it still catches the missed cron window. + * Without a dedupKey, falls back to a simple isDue() check. + */ +function shouldRunCronNow(string $frequency, string $timezone, ?string $dedupKey = null, ?\Illuminate\Support\Carbon $executionTime = null): bool +{ + $cron = new \Cron\CronExpression($frequency); + $executionTime = ($executionTime ?? \Illuminate\Support\Carbon::now())->copy()->setTimezone($timezone); + + if ($dedupKey === null) { + return $cron->isDue($executionTime); + } + + $previousDue = \Illuminate\Support\Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true)); + $lastDispatched = Cache::get($dedupKey); + + $shouldFire = $lastDispatched === null + ? $cron->isDue($executionTime) + : $previousDue->gt(\Illuminate\Support\Carbon::parse($lastDispatched)); + + // Always write: seeds on first miss, refreshes on dispatch. + // 30-day static TTL covers all intervals; orphan keys self-clean. + Cache::put($dedupKey, ($shouldFire ? $executionTime : $previousDue)->toIso8601String(), 2592000); + + return $shouldFire; +} + function validate_timezone(string $timezone): bool { return in_array($timezone, timezone_identifiers_list()); diff --git a/composer.json b/composer.json index d4fb1eb8e..e2b16b31b 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "laravel/fortify": "^1.34.0", "laravel/framework": "^12.49.0", "laravel/horizon": "^5.43.0", + "laravel/nightwatch": "^1.24", "laravel/pail": "^1.2.4", "laravel/prompts": "^0.3.11|^0.3.11|^0.3.11", "laravel/sanctum": "^4.3.0", diff --git a/composer.lock b/composer.lock index 993835a42..3a66fdd5a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "19bb661d294e5cf623e68830604e4f60", + "content-hash": "40bddea995c1744e4aec517263109a2f", "packages": [ { "name": "aws/aws-crt-php", @@ -2065,6 +2065,100 @@ }, "time": "2026-02-21T14:20:09+00:00" }, + { + "name": "laravel/nightwatch", + "version": "v1.24.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/nightwatch.git", + "reference": "127e9bb9928f0fcf69b52b244053b393c90347c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/nightwatch/zipball/127e9bb9928f0fcf69b52b244053b393c90347c8", + "reference": "127e9bb9928f0fcf69b52b244053b393c90347c8", + "shasum": "" + }, + "require": { + "ext-zlib": "*", + "guzzlehttp/promises": "^2.0", + "laravel/framework": "^10.0|^11.0|^12.0|^13.0", + "monolog/monolog": "^3.6", + "nesbot/carbon": "^2.0|^3.0", + "php": "^8.2", + "psr/http-message": "^1.0|^2.0", + "psr/log": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-foundation": "^6.0|^7.0|^8.0", + "symfony/polyfill-php84": "^1.29" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.349", + "ext-pcntl": "*", + "ext-pdo": "*", + "guzzlehttp/guzzle": "^7.0", + "guzzlehttp/psr7": "^2.0", + "laravel/horizon": "^5.4", + "laravel/pint": "1.21.0", + "laravel/vapor-core": "^2.38.2", + "livewire/livewire": "^2.0|^3.0", + "mockery/mockery": "^1.0", + "mongodb/laravel-mongodb": "^4.0|^5.0", + "orchestra/testbench": "^8.0|^9.0|^10.0", + "orchestra/testbench-core": "^8.0|^9.0|^10.0", + "orchestra/workbench": "^8.0|^9.0|^10.0", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^10.0|^11.0|^12.0", + "singlestoredb/singlestoredb-laravel": "^1.0|^2.0", + "spatie/laravel-ignition": "^2.0", + "symfony/mailer": "^6.0|^7.0|^8.0", + "symfony/mime": "^6.0|^7.0|^8.0", + "symfony/var-dumper": "^6.0|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Nightwatch": "Laravel\\Nightwatch\\Facades\\Nightwatch" + }, + "providers": [ + "Laravel\\Nightwatch\\NightwatchServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "agent/helpers.php" + ], + "psr-4": { + "Laravel\\Nightwatch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The official Laravel Nightwatch package.", + "homepage": "https://nightwatch.laravel.com", + "keywords": [ + "Insights", + "laravel", + "monitoring" + ], + "support": { + "docs": "https://nightwatch.laravel.com/docs", + "issues": "https://github.com/laravel/nightwatch/issues", + "source": "https://github.com/laravel/nightwatch" + }, + "time": "2026-03-18T23:25:05+00:00" + }, { "name": "laravel/pail", "version": "v1.2.6", @@ -17209,5 +17303,5 @@ "php": "^8.4" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/config/constants.php b/config/constants.php index 9c6454cae..b0a772541 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.469', + 'version' => '4.0.0-beta.470', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.11', 'self_hosted' => env('SELF_HOSTED', true), @@ -55,6 +55,10 @@ 'is_scheduler_enabled' => env('SCHEDULER_ENABLED', true), ], + 'nightwatch' => [ + 'is_nightwatch_enabled' => env('NIGHTWATCH_ENABLED', false), + ], + 'docker' => [ 'minimum_required_version' => '24.0', ], diff --git a/config/logging.php b/config/logging.php index 1a75978f3..1dbb1135f 100644 --- a/config/logging.php +++ b/config/logging.php @@ -123,7 +123,7 @@ 'driver' => 'daily', 'path' => storage_path('logs/scheduled.log'), 'level' => 'debug', - 'days' => 1, + 'days' => 7, ], 'scheduled-errors' => [ diff --git a/database/migrations/2026_03_23_101720_add_uuid_to_local_persistent_volumes_table.php b/database/migrations/2026_03_23_101720_add_uuid_to_local_persistent_volumes_table.php new file mode 100644 index 000000000..6b4fb690d --- /dev/null +++ b/database/migrations/2026_03_23_101720_add_uuid_to_local_persistent_volumes_table.php @@ -0,0 +1,39 @@ +string('uuid')->nullable()->after('id'); + }); + + DB::table('local_persistent_volumes') + ->whereNull('uuid') + ->orderBy('id') + ->chunk(1000, function ($volumes) { + foreach ($volumes as $volume) { + DB::table('local_persistent_volumes') + ->where('id', $volume->id) + ->update(['uuid' => (string) new Cuid2]); + } + }); + + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->string('uuid')->nullable(false)->unique()->change(); + }); + } + + public function down(): void + { + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->dropColumn('uuid'); + }); + } +}; diff --git a/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-setup b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-setup new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-setup @@ -0,0 +1 @@ + diff --git a/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run new file mode 100644 index 000000000..1166ccd08 --- /dev/null +++ b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run @@ -0,0 +1,12 @@ +#!/command/execlineb -P + +# Use with-contenv to ensure environment variables are available +with-contenv +cd /var/www/html + +foreground { + php + artisan + start:nightwatch +} + diff --git a/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/type b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/type new file mode 100644 index 000000000..5883cff0c --- /dev/null +++ b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/type @@ -0,0 +1 @@ +longrun diff --git a/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent b/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent @@ -0,0 +1 @@ + diff --git a/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-script b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-script new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/dependencies.d/init-script @@ -0,0 +1 @@ + diff --git a/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run new file mode 100644 index 000000000..80d73eadb --- /dev/null +++ b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run @@ -0,0 +1,11 @@ +#!/command/execlineb -P + +# Use with-contenv to ensure environment variables are available +with-contenv +cd /var/www/html +foreground { + php + artisan + start:nightwatch +} + diff --git a/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/type b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/type new file mode 100644 index 000000000..5883cff0c --- /dev/null +++ b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/type @@ -0,0 +1 @@ +longrun diff --git a/docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent b/docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/nightwatch-agent @@ -0,0 +1 @@ + diff --git a/openapi.json b/openapi.json index d119176a1..aec5a2843 100644 --- a/openapi.json +++ b/openapi.json @@ -3502,6 +3502,105 @@ } ] }, + "post": { + "tags": [ + "Applications" + ], + "summary": "Create Storage", + "description": "Create a persistent storage or file storage for an application.", + "operationId": "create-storage-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "type", + "mount_path" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage." + }, + "name": { + "type": "string", + "description": "Volume name (persistent only, required for persistent)." + }, + "mount_path": { + "type": "string", + "description": "The container mount path." + }, + "host_path": { + "type": "string", + "nullable": true, + "description": "The host path (persistent only, optional)." + }, + "content": { + "type": "string", + "nullable": true, + "description": "File content (file only, optional)." + }, + "is_directory": { + "type": "boolean", + "description": "Whether this is a directory mount (file only, default false)." + }, + "fs_path": { + "type": "string", + "description": "Host directory path (required when is_directory is true)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Storage created.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, "patch": { "tags": [ "Applications" @@ -3527,13 +3626,16 @@ "application\/json": { "schema": { "required": [ - "id", "type" ], "properties": { + "uuid": { + "type": "string", + "description": "The UUID of the storage (preferred)." + }, "id": { "type": "integer", - "description": "The ID of the storage." + "description": "The ID of the storage (deprecated, use uuid instead)." }, "type": { "type": "string", @@ -3603,6 +3705,70 @@ ] } }, + "\/applications\/{uuid}\/storages\/{storage_uuid}": { + "delete": { + "tags": [ + "Applications" + ], + "summary": "Delete Storage", + "description": "Delete a persistent storage or file storage by application UUID.", + "operationId": "delete-storage-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "description": "UUID of the storage.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Storage deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/cloud-tokens": { "get": { "tags": [ @@ -6513,6 +6679,333 @@ ] } }, + "\/databases\/{uuid}\/storages": { + "get": { + "tags": [ + "Databases" + ], + "summary": "List Storages", + "description": "List all persistent storages and file storages by database UUID.", + "operationId": "list-storages-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "All storages by database UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "persistent_storages": { + "type": "array", + "items": { + "type": "object" + } + }, + "file_storages": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Databases" + ], + "summary": "Create Storage", + "description": "Create a persistent storage or file storage for a database.", + "operationId": "create-storage-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "type", + "mount_path" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage." + }, + "name": { + "type": "string", + "description": "Volume name (persistent only, required for persistent)." + }, + "mount_path": { + "type": "string", + "description": "The container mount path." + }, + "host_path": { + "type": "string", + "nullable": true, + "description": "The host path (persistent only, optional)." + }, + "content": { + "type": "string", + "nullable": true, + "description": "File content (file only, optional)." + }, + "is_directory": { + "type": "boolean", + "description": "Whether this is a directory mount (file only, default false)." + }, + "fs_path": { + "type": "string", + "description": "Host directory path (required when is_directory is true)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Storage created.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Databases" + ], + "summary": "Update Storage", + "description": "Update a persistent storage or file storage by database UUID.", + "operationId": "update-storage-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "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)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Storage updated.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/storages\/{storage_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete Storage", + "description": "Delete a persistent storage or file storage by database UUID.", + "operationId": "delete-storage-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "description": "UUID of the storage.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Storage deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/deployments": { "get": { "tags": [ @@ -11238,6 +11731,338 @@ ] } }, + "\/services\/{uuid}\/storages": { + "get": { + "tags": [ + "Services" + ], + "summary": "List Storages", + "description": "List all persistent storages and file storages by service UUID.", + "operationId": "list-storages-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "All storages by service UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "persistent_storages": { + "type": "array", + "items": { + "type": "object" + } + }, + "file_storages": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Services" + ], + "summary": "Create Storage", + "description": "Create a persistent storage or file storage for a service sub-resource.", + "operationId": "create-storage-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "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)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Storage created.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Services" + ], + "summary": "Update Storage", + "description": "Update a persistent storage or file storage by service UUID.", + "operationId": "update-storage-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "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)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Storage updated.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/storages\/{storage_uuid}": { + "delete": { + "tags": [ + "Services" + ], + "summary": "Delete Storage", + "description": "Delete a persistent storage or file storage by service UUID.", + "operationId": "delete-storage-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "description": "UUID of the storage.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Storage deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/teams": { "get": { "tags": [ diff --git a/openapi.yaml b/openapi.yaml index 7064be28a..93038ce80 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2204,6 +2204,73 @@ paths: security: - bearerAuth: [] + post: + tags: + - Applications + summary: 'Create Storage' + description: 'Create a persistent storage or file storage for an application.' + operationId: create-storage-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - type + - mount_path + properties: + type: + type: string + enum: [persistent, file] + description: 'The type of storage.' + name: + type: string + description: 'Volume name (persistent only, required for persistent).' + mount_path: + type: string + description: 'The container mount path.' + host_path: + type: string + nullable: true + description: 'The host path (persistent only, optional).' + content: + type: string + nullable: true + description: 'File content (file only, optional).' + is_directory: + type: boolean + description: 'Whether this is a directory mount (file only, default false).' + fs_path: + type: string + description: 'Host directory path (required when is_directory is true).' + type: object + additionalProperties: false + responses: + '201': + description: 'Storage created.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] patch: tags: - Applications @@ -2225,12 +2292,14 @@ paths: application/json: schema: required: - - id - type properties: + uuid: + type: string + description: 'The UUID of the storage (preferred).' id: type: integer - description: 'The ID of the storage.' + description: 'The ID of the storage (deprecated, use uuid instead).' type: type: string enum: [persistent, file] @@ -2272,6 +2341,48 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/storages/{storage_uuid}': + delete: + tags: + - Applications + summary: 'Delete Storage' + description: 'Delete a persistent storage or file storage by application UUID.' + operationId: delete-storage-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: storage_uuid + in: path + description: 'UUID of the storage.' + required: true + schema: + type: string + responses: + '200': + description: 'Storage deleted.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /cloud-tokens: get: tags: @@ -4209,6 +4320,219 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/storages': + get: + tags: + - Databases + summary: 'List Storages' + description: 'List all persistent storages and file storages by database UUID.' + operationId: list-storages-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + responses: + '200': + description: 'All storages by database UUID.' + content: + application/json: + schema: + properties: + persistent_storages: { type: array, items: { type: object } } + file_storages: { type: array, items: { type: object } } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Databases + summary: 'Create Storage' + description: 'Create a persistent storage or file storage for a database.' + operationId: create-storage-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - type + - mount_path + properties: + type: + type: string + enum: [persistent, file] + description: 'The type of storage.' + name: + type: string + description: 'Volume name (persistent only, required for persistent).' + mount_path: + type: string + description: 'The container mount path.' + host_path: + type: string + nullable: true + description: 'The host path (persistent only, optional).' + content: + type: string + nullable: true + description: 'File content (file only, optional).' + is_directory: + type: boolean + description: 'Whether this is a directory mount (file only, default false).' + fs_path: + type: string + description: 'Host directory path (required when is_directory is true).' + type: object + additionalProperties: false + responses: + '201': + description: 'Storage created.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + patch: + tags: + - Databases + summary: 'Update Storage' + description: 'Update a persistent storage or file storage by database UUID.' + operationId: update-storage-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.' + required: true + content: + application/json: + schema: + 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).' + type: object + additionalProperties: false + responses: + '200': + description: 'Storage updated.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/storages/{storage_uuid}': + delete: + tags: + - Databases + summary: 'Delete Storage' + description: 'Delete a persistent storage or file storage by database UUID.' + operationId: delete-storage-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + - + name: storage_uuid + in: path + description: 'UUID of the storage.' + required: true + schema: + type: string + responses: + '200': + description: 'Storage deleted.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /deployments: get: tags: @@ -7070,6 +7394,223 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/storages': + get: + tags: + - Services + summary: 'List Storages' + description: 'List all persistent storages and file storages by service UUID.' + operationId: list-storages-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + responses: + '200': + description: 'All storages by service UUID.' + content: + application/json: + schema: + properties: + persistent_storages: { type: array, items: { type: object } } + file_storages: { type: array, items: { type: object } } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Services + summary: 'Create Storage' + description: 'Create a persistent storage or file storage for a service sub-resource.' + operationId: create-storage-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + 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).' + type: object + additionalProperties: false + responses: + '201': + description: 'Storage created.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + patch: + tags: + - Services + summary: 'Update Storage' + description: 'Update a persistent storage or file storage by service UUID.' + operationId: update-storage-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.' + required: true + content: + application/json: + schema: + 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).' + type: object + additionalProperties: false + responses: + '200': + description: 'Storage updated.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/storages/{storage_uuid}': + delete: + tags: + - Services + summary: 'Delete Storage' + description: 'Delete a persistent storage or file storage by service UUID.' + operationId: delete-storage-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: storage_uuid + in: path + description: 'UUID of the storage.' + required: true + schema: + type: string + responses: + '200': + description: 'Storage deleted.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /teams: get: tags: diff --git a/other/nightly/install.sh b/other/nightly/install.sh index 921ba6a92..09406118c 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -20,7 +20,7 @@ DATE=$(date +"%Y%m%d-%H%M%S") OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"') ENV_FILE="/data/coolify/source/.env" -DOCKER_VERSION="27.0" +DOCKER_VERSION="latest" # TODO: Ask for a user CURRENT_USER=$USER @@ -499,13 +499,10 @@ fi install_docker() { set +e - curl -s https://releases.rancher.com/install-docker/${DOCKER_VERSION}.sh | sh 2>&1 || true + curl -fsSL https://get.docker.com | sh 2>&1 || true if ! [ -x "$(command -v docker)" ]; then - curl -s https://get.docker.com | sh -s -- --version ${DOCKER_VERSION} 2>&1 - if ! [ -x "$(command -v docker)" ]; then - echo "Automated Docker installation failed. Trying manual installation." - install_docker_manually - fi + echo "Automated Docker installation failed. Trying manual installation." + install_docker_manually fi set -e } @@ -548,16 +545,6 @@ if ! [ -x "$(command -v docker)" ]; then echo " - Docker is not installed. Installing Docker. It may take a while." getAJoke case "$OS_TYPE" in - "almalinux") - dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1 - dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 - if ! [ -x "$(command -v docker)" ]; then - echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." - exit 1 - fi - systemctl start docker >/dev/null 2>&1 - systemctl enable docker >/dev/null 2>&1 - ;; "alpine" | "postmarketos") apk add docker docker-cli-compose >/dev/null 2>&1 rc-update add docker default >/dev/null 2>&1 @@ -569,8 +556,9 @@ if ! [ -x "$(command -v docker)" ]; then fi ;; "arch") - pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1 + pacman -Syu --noconfirm --needed docker docker-compose >/dev/null 2>&1 systemctl enable docker.service >/dev/null 2>&1 + systemctl start docker.service >/dev/null 2>&1 if ! [ -x "$(command -v docker)" ]; then echo " - Failed to install Docker with pacman. Try to install it manually." echo " Please visit https://wiki.archlinux.org/title/docker for more information." @@ -581,7 +569,7 @@ if ! [ -x "$(command -v docker)" ]; then dnf install docker -y >/dev/null 2>&1 DOCKER_CONFIG=${DOCKER_CONFIG:-/usr/local/lib/docker} mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1 - curl -sL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 + curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 systemctl start docker >/dev/null 2>&1 systemctl enable docker >/dev/null 2>&1 @@ -591,34 +579,28 @@ if ! [ -x "$(command -v docker)" ]; then exit 1 fi ;; - "centos" | "fedora" | "rhel" | "tencentos") - if [ -x "$(command -v dnf5)" ]; then - # dnf5 is available - dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo --overwrite >/dev/null 2>&1 - else - # dnf5 is not available, use dnf - dnf config-manager --add-repo=https://download.docker.com/linux/$OS_TYPE/docker-ce.repo >/dev/null 2>&1 - fi + "almalinux" | "tencentos") + dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1 dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 + systemctl start docker >/dev/null 2>&1 + systemctl enable docker >/dev/null 2>&1 if ! [ -x "$(command -v docker)" ]; then echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." exit 1 fi - systemctl start docker >/dev/null 2>&1 - systemctl enable docker >/dev/null 2>&1 ;; - "ubuntu" | "debian" | "raspbian") + "ubuntu" | "debian" | "raspbian" | "centos" | "fedora" | "rhel" | "sles") install_docker if ! [ -x "$(command -v docker)" ]; then - echo " - Automated Docker installation failed. Trying manual installation." - install_docker_manually + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 fi ;; *) install_docker if ! [ -x "$(command -v docker)" ]; then - echo " - Automated Docker installation failed. Trying manual installation." - install_docker_manually + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 fi ;; esac @@ -627,6 +609,19 @@ else echo " - Docker is installed." fi +# Verify minimum Docker version +MIN_DOCKER_VERSION=24 +INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1) +if [ -z "$INSTALLED_DOCKER_VERSION" ]; then + echo " - WARNING: Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed." +elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then + echo " - ERROR: Docker version $INSTALLED_DOCKER_VERSION is too old. Coolify requires Docker $MIN_DOCKER_VERSION or newer." + echo " Please upgrade Docker: https://docs.docker.com/engine/install/" + exit 1 +else + echo " - Docker version $(docker version --format '{{.Server.Version}}' 2>/dev/null) meets minimum requirement ($MIN_DOCKER_VERSION+)." +fi + log_section "Step 4/9: Checking Docker configuration" echo "4/9 Checking Docker configuration..." diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 7564f625e..c2ab7a7c1 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.469" + "version": "4.0.0-beta.470" }, "nightly": { "version": "4.0.0" @@ -13,17 +13,17 @@ "version": "1.0.11" }, "sentinel": { - "version": "0.0.19" + "version": "0.0.21" } }, "traefik": { - "v3.6": "3.6.5", + "v3.6": "3.6.11", "v3.5": "3.5.6", "v3.4": "3.4.5", "v3.3": "3.3.7", "v3.2": "3.2.5", "v3.1": "3.1.7", "v3.0": "3.0.4", - "v2.11": "2.11.32" + "v2.11": "2.11.40" } } diff --git a/phpunit.xml b/phpunit.xml index 6716b6b84..5d55acf75 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -22,6 +22,7 @@ + diff --git a/public/svgs/espocrm.svg b/public/svgs/espocrm.svg new file mode 100644 index 000000000..79d96f8c3 --- /dev/null +++ b/public/svgs/espocrm.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 615512b94..c1e8a3e54 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -190,7 +190,7 @@ class="relative w-auto h-auto"> @endif