currentTeamOrFail($request); $this->authorize('create', [V5Application::class, $currentTeam]); $projects = $this->projects($currentTeam); [$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects); if ($selectedProject === null || $selectedEnvironment === null) { return response()->json([ 'message' => 'Select a project and environment before deploying nginx.', ], 422); } $project = $this->projectQuery($currentTeam) ->where('uuid', $selectedProject['uuid']) ->first(); if (! $project instanceof Project) { abort(403); } $environment = $this->selectedEnvironment($project, $selectedEnvironment['uuid']); if (! $environment instanceof Environment) { abort(403); } $validated = $request->validate([ 'server_uuid' => ['nullable', 'string', 'max:255'], 'image' => ['nullable', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\/:@-]*$/'], ]); $image = trim($validated['image'] ?? '') ?: self::DEFAULT_NGINX_IMAGE; $server = V5Server::query() ->where('team_id', $currentTeam->id) ->when( isset($validated['server_uuid']), fn (Builder $query) => $query->where('uuid', $validated['server_uuid']), fn (Builder $query) => $query ->orderByRaw('last_bootstrapped_at is null') ->orderBy('name') ) ->first(); if (! $server instanceof V5Server) { return response()->json([ 'message' => 'Add a v5 server before deploying nginx.', ], 422); } if ($server->status !== ServerStatus::Installed->value || $server->last_bootstrapped_at === null) { return response()->json([ 'message' => "Bootstrap server {$server->name} before deploying to it.", ], 422); } $canvasPosition = $this->nextApplicationCanvasPosition($currentTeam, $project, $environment); $application = V5Application::query()->create([ 'team_id' => $currentTeam->id, 'project_id' => $project->id, 'environment_id' => $environment->id, 'server_id' => $server->id, 'created_by_user_id' => $request->user()->id, 'name' => 'nginx-test', 'image' => $image, 'container_name' => 'coolify-v5-nginx-'.strtolower((string) Str::ulid()), 'status' => ApplicationStatus::Creating->value, 'status_message' => 'Starting nginx container.', 'mesh_namespace' => 'default', 'canvas_x' => $canvasPosition['canvas_x'], 'canvas_y' => $canvasPosition['canvas_y'], ]); V5DeployApplicationJob::dispatch($application->id); return response()->json([ 'application' => $this->serializeApplication($application), ], 202); } public function refresh(Request $request, FluxClient $fluxClient): JsonResponse { $currentTeam = $this->currentTeamOrFail($request); $projects = $this->projects($currentTeam); [$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects); if ($selectedProject === null || $selectedEnvironment === null) { return response()->json([ 'message' => 'Select a project and environment before refreshing applications.', ], 422); } $applications = $this->applicationQuery($currentTeam, $selectedProject, $selectedEnvironment) ->with('server') ->get(); $errors = []; $applications ->groupBy('server_id') ->each(function (Collection $serverApplications) use ($fluxClient, &$errors): void { /** @var V5Application|null $firstApplication */ $firstApplication = $serverApplications->first(); $server = $firstApplication?->server; $hostId = $server?->fluxHostId(); if (! $server instanceof V5Server || ! is_string($hostId) || $hostId === '') { $errors[] = 'A server is missing its Flux host id.'; return; } // The moment we query coold is the observation time for the rows // this refresh writes, so a fresher webhook always wins the // status_observed_at watermark and is never clobbered. $observedAt = CarbonImmutable::now(); try { $containers = collect($fluxClient->listContainers($hostId)); } catch (\Throwable $e) { $errors[] = $e->getMessage(); return; } $serverApplications->each(function (V5Application $application) use ($containers, $observedAt): void { $container = $containers->first(function (array $container) use ($application): bool { return ($application->runtime_container_id !== null && ($container['id'] ?? null) === $application->runtime_container_id) || ($container['name'] ?? null) === $application->container_name; }); if (! is_array($container)) { // A creating application without a container id simply has // not materialized yet; the deploy job will settle it. if ($application->status === ApplicationStatus::Creating->value && $application->runtime_container_id === null) { return; } if (StatusObservation::isStale($observedAt, $application->status_observed_at, 'application status', ['application_id' => $application->id])) { return; } $application->update([ 'status' => ApplicationStatus::Exited->value, 'status_message' => 'Container not found on server.', 'status_observed_at' => $observedAt, ]); return; } if (StatusObservation::isStale($observedAt, $application->status_observed_at, 'application status', ['application_id' => $application->id])) { return; } $rawState = is_string($container['state'] ?? null) && $container['state'] !== '' ? $container['state'] : null; $application->update([ 'status' => StatusObservation::normalize($rawState, ApplicationStatus::class) ?? ApplicationStatus::Unknown->value, 'status_message' => 'Container state refreshed from coold.', 'status_observed_at' => $observedAt, 'runtime_container_id' => is_string($container['id'] ?? null) ? $container['id'] : $application->runtime_container_id, ]); }); }); V5Server::query() ->where('team_id', $currentTeam->id) ->orderBy('name') ->get() ->filter(fn (V5Server $server) => $server->isIngress()) ->each(function (V5Server $server) use ($fluxClient, &$errors): void { $hostId = $server->fluxHostId(); if (! is_string($hostId) || $hostId === '') { $errors[] = "Caddy ingress server {$server->name} is missing its Flux host id."; return; } try { $containers = collect($fluxClient->listContainers($hostId)); } catch (\Throwable $e) { $errors[] = $e->getMessage(); return; } $container = $containers->first(fn (array $container) => ($container['name'] ?? null) === 'coolify-v5-caddy'); $rawState = is_array($container) && is_string($container['state'] ?? null) && $container['state'] !== '' ? $container['state'] : null; $state = $rawState !== null ? (StatusObservation::normalize($rawState, IngressStatus::class) ?? IngressStatus::Unknown->value) : IngressStatus::Exited->value; $server->update([ 'ingress_type' => 'caddy', 'ingress_status' => $state, 'last_status_check' => 'flux', 'last_status_output' => 'Caddy ingress state refreshed from coold.', 'last_status_checked_at' => now(), ]); }); return response()->json([ 'applications' => $this->applicationQuery($currentTeam, $selectedProject, $selectedEnvironment) ->with('server') ->orderBy('created_at') ->get() ->map(fn (V5Application $application) => $this->serializeApplication($application)) ->all(), 'caddyIngresses' => $this->caddyIngresses($currentTeam), 'errors' => $errors, ]); } public function logs(Request $request, V5Application $application): JsonResponse { $currentTeam = $this->currentTeamOrFail($request); $this->authorize('view', [$application, $currentTeam]); $application->loadMissing('server'); $server = $application->server; $hostId = $server?->fluxHostId(); $containerId = $application->runtime_container_id; $logs = null; $logsError = null; // A container id only appears once the deploy actually created one; a // deploy that failed before that (e.g. host not connected) has none, so // there is nothing to fetch and the frontend just shows the status. if (is_string($containerId) && $containerId !== '' && $server instanceof V5Server && $server->status !== ServerStatus::Unreachable->value && is_string($hostId) && $hostId !== '') { try { $logs = app(FluxClient::class)->containerLogs($hostId, $containerId); } catch (UnsupportedCooldVerb $exception) { $logsError = "This node's coold does not support container logs."; } catch (\RuntimeException $exception) { Log::warning('V5 application container logs request failed', [ 'application_id' => $application->id, 'message' => $exception->getMessage(), ]); $logsError = 'Could not fetch container logs through Flux. Check the Flux and coold status, then try again.'; } } return response()->json([ 'status' => $application->status, 'statusMessage' => $application->status_message, 'containerId' => $containerId, 'logs' => $logs, 'logsError' => $logsError, ]); } public function updatePosition(Request $request, V5Application $application): JsonResponse { $currentTeam = $this->currentTeamOrFail($request); $this->authorize('update', [$application, $currentTeam]); $validated = $request->validate([ 'canvas_x' => ['required', 'integer', 'min:-100000', 'max:100000'], 'canvas_y' => ['required', 'integer', 'min:-100000', 'max:100000'], ]); $application->update([ 'canvas_x' => $validated['canvas_x'], 'canvas_y' => $validated['canvas_y'], ]); return response()->json([ 'application' => $this->serializeApplication($application->refresh()->load('server')), ]); } public function updateIngress(Request $request, V5Application $application): JsonResponse { $currentTeam = $this->currentTeamOrFail($request); $this->authorize('updateIngress', [$application, $currentTeam]); $validated = $request->validate([ 'ingress_enabled' => ['required', 'boolean'], 'internal_port' => ['nullable', 'integer', 'min:1', 'max:65535'], 'domains' => [Rule::requiredIf(fn () => $request->boolean('ingress_enabled')), 'array', 'min:1'], 'domains.*' => ['required', 'string', 'max:255', 'distinct:ignore_case', new ValidHostname], ]); $application->loadMissing('server'); if ($validated['ingress_enabled'] && ! $application->server?->isIngress()) { return response()->json([ 'message' => 'Enable ingress on the server before enabling app ingress.', ], 422); } if ($validated['ingress_enabled'] && array_key_exists('domains', $validated)) { $conflict = $this->conflictingApplicationDomain($application, $validated['domains']); if ($conflict instanceof V5ApplicationDomain) { return response()->json([ 'message' => "The domain {$conflict->domain} is already used by application \"{$conflict->application?->name}\" on this server.", ], 422); } } $originalAttributes = $application->only(['ingress_enabled', 'internal_port']); $originalDomains = $application->domains()->pluck('domain')->all(); DB::transaction(function () use ($application, $validated): void { $application->update([ 'ingress_enabled' => $validated['ingress_enabled'], 'internal_port' => $validated['internal_port'] ?? null, ]); if (array_key_exists('domains', $validated)) { $application->domains()->delete(); collect($validated['domains']) ->map(fn (string $domain) => trim($domain)) ->filter() ->unique() ->each(fn (string $domain) => V5ApplicationDomain::query()->create([ 'application_id' => $application->id, 'domain' => $domain, ])); } }); $application->refresh()->load(['server', 'domains']); if ($application->server?->isIngress() && $application->server->status === ServerStatus::Installed->value) { try { StartCaddyIngress::run($application->server); } catch (\RuntimeException $exception) { $this->restoreApplicationIngress($application, $originalAttributes, $originalDomains); return $this->ingressSyncErrorResponse($exception); } } return response()->json([ 'application' => $this->serializeApplication($application), ]); } public function updateCaddyIngressPosition(Request $request, V5Server $server): JsonResponse { $currentTeam = $this->currentTeamOrFail($request); $this->authorize('updateCanvasPosition', [$server, $currentTeam]); $validated = $request->validate([ 'canvas_x' => ['required', 'integer', 'min:-100000', 'max:100000'], 'canvas_y' => ['required', 'integer', 'min:-100000', 'max:100000'], ]); $server->update([ 'canvas_x' => $validated['canvas_x'], 'canvas_y' => $validated['canvas_y'], ]); return response()->json([ 'caddyIngress' => $this->serializeCaddyIngress($server->refresh()), ]); } public function destroy(Request $request, V5Application $application, FluxClient $fluxClient): Response|JsonResponse { $currentTeam = $this->currentTeamOrFail($request); $this->authorize('delete', [$application, $currentTeam]); $application->loadMissing(['server', 'domains']); $server = $application->server; $connections = $this->applicationResourceConnections($application); if ($request->boolean('delete_locally')) { $this->deleteApplicationLocally($application, $connections); return response()->noContent(); } $oldFirewallRules = $connections ->flatMap(function (ResourceConnection $connection): Collection { // Deletion must never be blocked by an endpoint that already lost // its server; those rules can no longer be revoked anyway. try { return $this->firewallSync->rulesFor($connection->load('rules')); } catch (\RuntimeException $exception) { report($exception); return collect(); } }); $originalIngressAttributes = null; $originalIngressDomains = []; $ingressConfigurationChanged = false; try { $this->firewallSync->sync($fluxClient, $oldFirewallRules, collect()); } catch (\RuntimeException $exception) { report($exception); return response()->json([ 'message' => 'Could not sync firewall rules through Flux.', 'detail' => $exception->getMessage(), ], 502); } if ($server instanceof V5Server && $server->isIngress() && $server->status === ServerStatus::Installed->value && $application->ingress_enabled) { $originalIngressAttributes = $application->only(['ingress_enabled', 'internal_port']); $originalIngressDomains = $application->domains()->pluck('domain')->all(); DB::transaction(function () use ($application): void { $application->update([ 'ingress_enabled' => false, 'internal_port' => null, ]); $application->domains()->delete(); }); try { StartCaddyIngress::run($server); $ingressConfigurationChanged = true; } catch (\RuntimeException $exception) { $this->restoreApplicationIngress($application, $originalIngressAttributes, $originalIngressDomains); return $this->ingressSyncErrorResponse($exception); } } $error = DestroyNginxApplication::run($application); if ($error !== null) { if ($originalIngressAttributes !== null) { $this->restoreApplicationIngress($application, $originalIngressAttributes, $originalIngressDomains); if ($ingressConfigurationChanged && $server instanceof V5Server) { try { StartCaddyIngress::run($server); } catch (\RuntimeException $exception) { report($exception); } } } try { $this->firewallSync->sync($fluxClient, collect(), $oldFirewallRules); } catch (\RuntimeException $exception) { report($exception); } return response()->json([ 'message' => $error, 'can_delete_locally' => true, ], 422); } $this->deleteApplicationLocally($application, $connections); return response()->noContent(); } /** * @param Collection $connections */ private function deleteApplicationLocally(V5Application $application, Collection $connections): void { DB::transaction(function () use ($application, $connections): void { $connections->each(function (ResourceConnection $connection): void { $connection->rules()->delete(); $connection->delete(); }); $application->delete(); }); } /** * @return Collection */ private function applicationResourceConnections(V5Application $application): Collection { return ResourceConnection::query() ->where('team_id', $application->team_id) ->where(function (Builder $query) use ($application): void { $query ->where(function (Builder $query) use ($application): void { $query ->where('resource_one_type', $application->getMorphClass()) ->where('resource_one_id', $application->id); }) ->orWhere(function (Builder $query) use ($application): void { $query ->where('resource_two_type', $application->getMorphClass()) ->where('resource_two_id', $application->id); }); }) ->with('rules') ->get(); } /** * @return array{canvas_x: int, canvas_y: int} */ private function nextApplicationCanvasPosition(Team $currentTeam, Project $project, Environment $environment): array { $existingApplications = V5Application::query() ->where('team_id', $currentTeam->id) ->where('project_id', $project->id) ->where('environment_id', $environment->id) ->get(['canvas_x', 'canvas_y']); $horizontalStep = CanvasResourceSerializer::CARD_WIDTH + CanvasResourceSerializer::CARD_GAP; $verticalStep = CanvasResourceSerializer::CARD_HEIGHT + CanvasResourceSerializer::CARD_GAP; for ($row = 0; $row < 100; $row++) { for ($column = 0; $column < 100; $column++) { $candidate = [ 'canvas_x' => $column * $horizontalStep, 'canvas_y' => $row * $verticalStep, ]; if (! $this->canvasPositionCollides($candidate, $existingApplications)) { return $candidate; } } } return [ 'canvas_x' => $existingApplications->max('canvas_x') + $horizontalStep, 'canvas_y' => 0, ]; } /** * @param array{canvas_x: int, canvas_y: int} $candidate * @param Collection $existingApplications */ private function canvasPositionCollides(array $candidate, Collection $existingApplications): bool { return $existingApplications->contains(function (V5Application $application) use ($candidate) { return abs($candidate['canvas_x'] - $application->canvas_x) < CanvasResourceSerializer::CARD_WIDTH + CanvasResourceSerializer::CARD_GAP && abs($candidate['canvas_y'] - $application->canvas_y) < CanvasResourceSerializer::CARD_HEIGHT + CanvasResourceSerializer::CARD_GAP; }); } /** * @param array $domains */ private function conflictingApplicationDomain(V5Application $application, array $domains): ?V5ApplicationDomain { $normalizedDomains = collect($domains) ->map(fn (string $domain) => Str::lower(trim($domain))) ->filter() ->values(); if ($normalizedDomains->isEmpty()) { return null; } return V5ApplicationDomain::query() ->whereIn(DB::raw('LOWER(domain)'), $normalizedDomains->all()) ->whereHas('application', fn (Builder $query) => $query ->where('server_id', $application->server_id) ->whereKeyNot($application->id) ->where('ingress_enabled', true)) ->with('application:id,name') ->first(); } /** * @param array $attributes * @param array $domains */ private function restoreApplicationIngress(V5Application $application, array $attributes, array $domains): void { DB::transaction(function () use ($application, $attributes, $domains): void { $application->update($attributes); $application->domains()->delete(); foreach ($domains as $domain) { V5ApplicationDomain::query()->create([ 'application_id' => $application->id, 'domain' => $domain, ]); } }); } }