Split V5 dashboard behavior into domain controllers and policies, add agent token rotation/revocation, status reconciliation jobs, ingress firewall syncing, and canvas connection APIs. Add migrations for V5 status tracking, server capabilities, resource connection aliases, and revoked agent tokens.
761 lines
29 KiB
PHP
761 lines
29 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\V5;
|
|
|
|
use App\Actions\V5\Proxy\StartCaddyIngress;
|
|
use App\Actions\V5\Proxy\StopCaddyIngress;
|
|
use App\Actions\V5\Server\RemoveBootstrapMarker;
|
|
use App\Enums\V5\ServerStatus;
|
|
use App\Events\V5ClusterUpdated;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Controllers\V5\Concerns\HandlesIngressSyncErrors;
|
|
use App\Http\Controllers\V5\Concerns\ResolvesCurrentTeam;
|
|
use App\Http\Controllers\V5\Concerns\ValidatesBuilderConfiguration;
|
|
use App\Jobs\V5BootstrapServerJob;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\V5\Application as V5Application;
|
|
use App\Models\V5\Cluster as V5Cluster;
|
|
use App\Models\V5\Server as V5Server;
|
|
use App\Rules\ValidServerIp;
|
|
use App\Services\Flux\FluxClient;
|
|
use App\Support\V5\ClusterSerializer;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Response;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Validation\Rule;
|
|
|
|
class ServerController extends Controller
|
|
{
|
|
use HandlesIngressSyncErrors;
|
|
use ResolvesCurrentTeam;
|
|
use ValidatesBuilderConfiguration;
|
|
|
|
public function store(Request $request, V5Cluster $cluster): JsonResponse
|
|
{
|
|
$currentTeam = $this->currentTeamOrFail($request);
|
|
$this->authorize('create', [V5Server::class, $currentTeam, $cluster]);
|
|
|
|
$validated = $request->validate([
|
|
'name' => ['required', 'string', 'max:255'],
|
|
'host' => [
|
|
'required',
|
|
'string',
|
|
'max:255',
|
|
$this->noControlCharactersRule(),
|
|
new ValidServerIp,
|
|
Rule::unique('v5_servers', 'host')
|
|
->where('team_id', $currentTeam->id)
|
|
->where('ssh_port', (int) $request->input('ssh_port', 22)),
|
|
],
|
|
'ssh_user' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._-]+$/', $this->noControlCharactersRule()],
|
|
'ssh_port' => ['required', 'integer', 'min:1', 'max:65535'],
|
|
'private_key_uuid' => [
|
|
'required',
|
|
'string',
|
|
Rule::exists('private_keys', 'uuid')->where('team_id', $currentTeam->id),
|
|
],
|
|
'node_address' => [
|
|
'nullable',
|
|
'string',
|
|
'max:255',
|
|
$this->noControlCharactersRule(),
|
|
new ValidServerIp,
|
|
Rule::unique('v5_servers', 'node_address')->where('team_id', $currentTeam->id),
|
|
],
|
|
'builder_enabled' => ['sometimes', 'boolean'],
|
|
'builder_capacity' => $this->builderCapacityRules(
|
|
$this->requestedBuilderEnabled($request, $cluster->builder_enabled)
|
|
),
|
|
'builder_cpu_quota' => ['sometimes', 'string', 'max:32'],
|
|
'wireguard_listen_port_override' => ['nullable', 'integer', 'min:1', 'max:65535'],
|
|
'wireguard_endpoint_override' => [
|
|
'nullable',
|
|
'string',
|
|
'max:255',
|
|
$this->noControlCharactersRule(),
|
|
$this->hostPortRule(),
|
|
Rule::unique('v5_servers', 'wireguard_endpoint_override')->where('cluster_id', $cluster->id),
|
|
],
|
|
'ingress_enabled' => ['sometimes', 'boolean'],
|
|
'ingress_type' => [
|
|
Rule::requiredIf(fn () => $request->boolean('ingress_enabled')),
|
|
'nullable',
|
|
'string',
|
|
Rule::in(['caddy']),
|
|
],
|
|
]);
|
|
|
|
$capacity = $this->clusterServerCapacity($cluster);
|
|
|
|
if ($capacity !== null && $cluster->servers()->count() >= $capacity) {
|
|
return response()->json([
|
|
'message' => "This cluster's network pools are full ({$capacity} server(s) max). Grow the pools or remove a server first.",
|
|
], 422);
|
|
}
|
|
|
|
$builderEnabled = (bool) ($validated['builder_enabled'] ?? $cluster->builder_enabled);
|
|
$ingressEnabled = (bool) ($validated['ingress_enabled'] ?? false);
|
|
$ingressType = $ingressEnabled ? $validated['ingress_type'] : null;
|
|
$builderCapacity = (int) ($validated['builder_capacity'] ?? $cluster->builder_capacity);
|
|
$builderCpuQuota = $validated['builder_cpu_quota'] ?? $cluster->builder_cpu_quota;
|
|
$devWireguardOverrides = $this->devLimaWireguardOverrides($validated['host'], (int) $validated['ssh_port']);
|
|
$privateKey = PrivateKey::query()
|
|
->where('team_id', $currentTeam->id)
|
|
->where('uuid', $validated['private_key_uuid'])
|
|
->firstOrFail();
|
|
|
|
V5Server::query()->create([
|
|
'team_id' => $currentTeam->id,
|
|
'cluster_id' => $cluster->id,
|
|
'created_by_user_id' => $request->user()->id,
|
|
'name' => $validated['name'],
|
|
'host' => $validated['host'],
|
|
'ssh_user' => $validated['ssh_user'],
|
|
'ssh_port' => $validated['ssh_port'],
|
|
'private_key_id' => $privateKey->id,
|
|
'status' => ServerStatus::Added->value,
|
|
'ingress_type' => $ingressType,
|
|
'is_ingress' => $ingressEnabled,
|
|
'builder_enabled' => $builderEnabled,
|
|
'builder_capacity' => $builderCapacity,
|
|
'builder_cpu_quota' => $builderCpuQuota,
|
|
'node_address' => $validated['node_address'] ?? $validated['host'],
|
|
'wireguard_listen_port_override' => $validated['wireguard_listen_port_override'] ?? $devWireguardOverrides['listen_port'],
|
|
'wireguard_endpoint_override' => $validated['wireguard_endpoint_override'] ?? $devWireguardOverrides['endpoint'],
|
|
]);
|
|
|
|
return response()->json([
|
|
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
|
], 201);
|
|
}
|
|
|
|
public function update(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse
|
|
{
|
|
$currentTeam = $this->currentTeamOrFail($request);
|
|
$this->authorize('update', [$server, $currentTeam, $cluster]);
|
|
|
|
$validated = $request->validate([
|
|
'builder_enabled' => ['required', 'boolean'],
|
|
'builder_capacity' => $this->builderCapacityRules(
|
|
$request->boolean('builder_enabled'),
|
|
required: true
|
|
),
|
|
'builder_cpu_quota' => ['required', 'string', 'max:32'],
|
|
'ingress_enabled' => ['sometimes', 'boolean'],
|
|
'ingress_type' => [
|
|
Rule::requiredIf(fn () => $request->boolean('ingress_enabled')),
|
|
'nullable',
|
|
'string',
|
|
Rule::in(['caddy']),
|
|
],
|
|
]);
|
|
|
|
$wasIngress = $server->isIngress();
|
|
$builderEnabled = (bool) $validated['builder_enabled'];
|
|
$ingressEnabled = (bool) ($validated['ingress_enabled'] ?? $wasIngress);
|
|
$ingressType = $ingressEnabled ? ($validated['ingress_type'] ?? $server->ingress_type ?? 'caddy') : null;
|
|
$originalServerAttributes = $server->only([
|
|
'is_ingress',
|
|
'ingress_type',
|
|
'ingress_status',
|
|
'builder_enabled',
|
|
'builder_capacity',
|
|
'builder_cpu_quota',
|
|
]);
|
|
|
|
// Stop the ingress before persisting the change: StopCaddyIngress needs
|
|
// the server's current ingress state, and a failed stop must leave the
|
|
// capability untouched.
|
|
if ($wasIngress && ! $ingressEnabled && $server->status === ServerStatus::Installed->value) {
|
|
try {
|
|
StopCaddyIngress::run($server);
|
|
} catch (\RuntimeException $exception) {
|
|
return $this->ingressSyncErrorResponse($exception);
|
|
}
|
|
}
|
|
|
|
$server->update([
|
|
'is_ingress' => $ingressEnabled,
|
|
'ingress_type' => $ingressType,
|
|
'builder_enabled' => $builderEnabled,
|
|
'builder_capacity' => (int) $validated['builder_capacity'],
|
|
'builder_cpu_quota' => $validated['builder_cpu_quota'],
|
|
]);
|
|
|
|
$server->refresh();
|
|
|
|
if (! $wasIngress && $ingressEnabled && $server->status === ServerStatus::Installed->value) {
|
|
try {
|
|
StartCaddyIngress::run($server);
|
|
} catch (\RuntimeException $exception) {
|
|
$server->update($originalServerAttributes);
|
|
|
|
return $this->ingressSyncErrorResponse($exception);
|
|
}
|
|
}
|
|
|
|
return response()->json([
|
|
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
|
]);
|
|
}
|
|
|
|
public function check(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse
|
|
{
|
|
$currentTeam = $this->currentTeamOrFail($request);
|
|
$this->authorize('check', [$server, $currentTeam, $cluster]);
|
|
|
|
if (! $server->privateKey instanceof PrivateKey) {
|
|
return response()->json([
|
|
'status' => 'failed',
|
|
'output' => 'No private key is attached to this server.',
|
|
'checkedAt' => now()->toJSON(),
|
|
]);
|
|
}
|
|
|
|
$keyDirectory = storage_path('app/ssh/keys');
|
|
if (! is_dir($keyDirectory)) {
|
|
mkdir($keyDirectory, 0700, true);
|
|
}
|
|
|
|
$keyLocation = tempnam($keyDirectory, 'v5_ssh_key_');
|
|
if ($keyLocation === false) {
|
|
return response()->json([
|
|
'status' => 'failed',
|
|
'output' => 'Could not create a temporary SSH key file.',
|
|
'checkedAt' => now()->toJSON(),
|
|
]);
|
|
}
|
|
|
|
file_put_contents($keyLocation, $server->privateKey->private_key);
|
|
chmod($keyLocation, 0600);
|
|
|
|
$target = "{$server->ssh_user}@{$server->host}";
|
|
$command = [
|
|
'ssh',
|
|
'-o',
|
|
'BatchMode=yes',
|
|
'-o',
|
|
'LogLevel=ERROR',
|
|
'-o',
|
|
'StrictHostKeyChecking=no',
|
|
'-o',
|
|
'UserKnownHostsFile=/dev/null',
|
|
'-o',
|
|
'ConnectTimeout=10',
|
|
'-o',
|
|
'IdentitiesOnly=yes',
|
|
'-i',
|
|
$keyLocation,
|
|
'-p',
|
|
(string) $server->ssh_port,
|
|
$target,
|
|
"printf 'SSH connection OK\n'; hostname; uname -srm; command -v docker || true; command -v podman || true",
|
|
];
|
|
|
|
try {
|
|
$result = Process::timeout(15)->run($command);
|
|
$output = trim($result->output()."\n".$result->errorOutput());
|
|
$status = $result->successful() ? 'reachable' : 'failed';
|
|
} catch (\Throwable $e) {
|
|
$output = $e->getMessage();
|
|
$status = 'failed';
|
|
} finally {
|
|
@unlink($keyLocation);
|
|
}
|
|
|
|
return response()->json([
|
|
'status' => $status,
|
|
'output' => str($output !== '' ? $output : 'No output returned.')->limit(10000)->toString(),
|
|
'checkedAt' => now()->toJSON(),
|
|
]);
|
|
}
|
|
|
|
public function cooldLogs(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse
|
|
{
|
|
$currentTeam = $this->currentTeamOrFail($request);
|
|
$this->authorize('viewDiagnostics', [$server, $currentTeam, $cluster]);
|
|
|
|
$validated = $request->validate([
|
|
'tail' => ['sometimes', 'integer', 'min:1', 'max:1000'],
|
|
]);
|
|
|
|
$hostId = $server->fluxHostId();
|
|
|
|
if (! is_string($hostId) || $hostId === '') {
|
|
return response()->json([
|
|
'message' => 'This server is missing its Flux host id.',
|
|
], 422);
|
|
}
|
|
|
|
try {
|
|
$output = $fluxClient->cooldLogs($hostId, (int) ($validated['tail'] ?? 200));
|
|
} catch (\Throwable $e) {
|
|
Log::warning('V5 coold logs request failed', [
|
|
'server_id' => $server->id,
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
|
|
if ($server->privateKey instanceof PrivateKey) {
|
|
try {
|
|
return response()->json([
|
|
'output' => $this->cooldLogsOverSsh($server, (int) ($validated['tail'] ?? 200)),
|
|
'source' => 'ssh',
|
|
'fetchedAt' => now()->toJSON(),
|
|
]);
|
|
} catch (\Throwable $sshException) {
|
|
Log::warning('V5 coold logs SSH fallback failed', [
|
|
'server_id' => $server->id,
|
|
'message' => $sshException->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
return response()->json([
|
|
'message' => 'Could not fetch coold logs through Flux. Check the Flux and coold status, then try again.',
|
|
], 502);
|
|
}
|
|
|
|
return response()->json([
|
|
'output' => $output,
|
|
'source' => 'flux',
|
|
'fetchedAt' => now()->toJSON(),
|
|
]);
|
|
}
|
|
|
|
private function cooldLogsOverSsh(V5Server $server, int $tail): string
|
|
{
|
|
return $this->runServerSshCommand(
|
|
$server,
|
|
'sudo -n journalctl -u coold -n '.max(1, min($tail, 1000)).' --no-pager -q || journalctl -u coold -n '.max(1, min($tail, 1000)).' --no-pager -q',
|
|
'SSH coold log command failed.',
|
|
);
|
|
}
|
|
|
|
private function runServerSshCommand(V5Server $server, string $remoteCommand, string $failureMessage): string
|
|
{
|
|
$keyDirectory = storage_path('app/ssh/keys');
|
|
if (! is_dir($keyDirectory)) {
|
|
mkdir($keyDirectory, 0700, true);
|
|
}
|
|
|
|
$keyLocation = tempnam($keyDirectory, 'v5_ssh_key_');
|
|
if ($keyLocation === false) {
|
|
throw new \RuntimeException('Could not create a temporary SSH key file.');
|
|
}
|
|
|
|
file_put_contents($keyLocation, $server->privateKey->private_key);
|
|
chmod($keyLocation, 0600);
|
|
|
|
try {
|
|
$result = Process::timeout(15)->run([
|
|
'ssh',
|
|
'-o',
|
|
'BatchMode=yes',
|
|
'-o',
|
|
'LogLevel=ERROR',
|
|
'-o',
|
|
'StrictHostKeyChecking=no',
|
|
'-o',
|
|
'UserKnownHostsFile=/dev/null',
|
|
'-o',
|
|
'ConnectTimeout=10',
|
|
'-o',
|
|
'IdentitiesOnly=yes',
|
|
'-i',
|
|
$keyLocation,
|
|
'-p',
|
|
(string) $server->ssh_port,
|
|
"{$server->ssh_user}@{$server->host}",
|
|
$remoteCommand,
|
|
]);
|
|
$output = trim($result->output()."\n".$result->errorOutput());
|
|
|
|
if (! $result->successful()) {
|
|
throw new \RuntimeException($output !== '' ? $output : $failureMessage);
|
|
}
|
|
|
|
return str($output)->limit(10000)->toString();
|
|
} finally {
|
|
@unlink($keyLocation);
|
|
}
|
|
}
|
|
|
|
public function corrosionTables(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse
|
|
{
|
|
$currentTeam = $this->currentTeamOrFail($request);
|
|
$this->authorize('viewDiagnostics', [$server, $currentTeam, $cluster]);
|
|
|
|
$validated = $request->validate([
|
|
'limit' => ['sometimes', 'integer', 'min:1', 'max:1000'],
|
|
]);
|
|
|
|
$hostId = $server->fluxHostId();
|
|
|
|
if (! is_string($hostId) || $hostId === '') {
|
|
return response()->json([
|
|
'message' => 'This server is missing its Flux host id.',
|
|
], 422);
|
|
}
|
|
|
|
try {
|
|
$output = $fluxClient->corrosionTables($hostId, (int) ($validated['limit'] ?? 200));
|
|
} catch (\Throwable $e) {
|
|
Log::warning('V5 corrosion tables request failed', [
|
|
'server_id' => $server->id,
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
|
|
if ($server->privateKey instanceof PrivateKey) {
|
|
try {
|
|
return response()->json([
|
|
'output' => $this->corrosionTablesOverSsh($server, $cluster, (int) ($validated['limit'] ?? 200)),
|
|
'source' => 'ssh',
|
|
'fetchedAt' => now()->toJSON(),
|
|
]);
|
|
} catch (\Throwable $sshException) {
|
|
Log::warning('V5 corrosion tables SSH fallback failed', [
|
|
'server_id' => $server->id,
|
|
'message' => $sshException->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
return response()->json([
|
|
'message' => 'Could not fetch corrosion tables through Flux. Check the Flux and coold status, then try again.',
|
|
], 502);
|
|
}
|
|
|
|
return response()->json([
|
|
'output' => $output,
|
|
'source' => 'flux',
|
|
'fetchedAt' => now()->toJSON(),
|
|
]);
|
|
}
|
|
|
|
private function corrosionTablesOverSsh(V5Server $server, V5Cluster $cluster, int $limit): string
|
|
{
|
|
$limit = max(1, min($limit, 1000));
|
|
$script = <<<'PYTHON'
|
|
python3 - <<'PY'
|
|
import json
|
|
import urllib.request
|
|
|
|
limit = __LIMIT__
|
|
url = "http://127.0.0.1:__PORT__/v1/queries"
|
|
|
|
def query(sql):
|
|
request = urllib.request.Request(
|
|
url,
|
|
data=json.dumps([sql, []]).encode(),
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=10) as response:
|
|
return json.loads(response.read().decode())
|
|
|
|
def quote_identifier(value):
|
|
return '"' + value.replace('"', '""') + '"'
|
|
|
|
tables = []
|
|
for row in query("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"):
|
|
name = row[0] if row else None
|
|
if not isinstance(name, str):
|
|
continue
|
|
|
|
identifier = quote_identifier(name)
|
|
columns = [column[1] for column in query(f"PRAGMA table_info({identifier})") if len(column) > 1]
|
|
rows = query(f"SELECT * FROM {identifier} LIMIT {limit}")
|
|
tables.append({"name": name, "columns": columns, "rows": rows})
|
|
|
|
print(json.dumps({"limit": limit, "tables": tables}, separators=(",", ":")))
|
|
PY
|
|
PYTHON;
|
|
|
|
return $this->runServerSshCommand($server, str_replace(
|
|
['__LIMIT__', '__PORT__'],
|
|
[(string) $limit, (string) $cluster->corrosion_api_port],
|
|
$script,
|
|
), 'SSH corrosion table command failed.');
|
|
}
|
|
|
|
public function firewallRules(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse
|
|
{
|
|
$currentTeam = $this->currentTeamOrFail($request);
|
|
$this->authorize('viewDiagnostics', [$server, $currentTeam, $cluster]);
|
|
|
|
$validated = $request->validate([
|
|
'namespace' => ['sometimes', 'string', 'max:63'],
|
|
]);
|
|
|
|
$hostId = $server->fluxHostId();
|
|
|
|
if (! is_string($hostId) || $hostId === '') {
|
|
return response()->json([
|
|
'message' => 'This server is missing its Flux host id.',
|
|
], 422);
|
|
}
|
|
|
|
try {
|
|
$rules = $fluxClient->listFirewallRules($hostId, (string) ($validated['namespace'] ?? ''));
|
|
} catch (\Throwable $e) {
|
|
Log::warning('V5 firewall rules request failed', [
|
|
'server_id' => $server->id,
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
|
|
return response()->json([
|
|
'message' => 'Could not fetch firewall rules through Flux. Check the Flux and coold status, then try again.',
|
|
], 502);
|
|
}
|
|
|
|
return response()->json([
|
|
'rules' => $rules,
|
|
'source' => 'flux',
|
|
'fetchedAt' => now()->toJSON(),
|
|
]);
|
|
}
|
|
|
|
public function bootstrap(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse
|
|
{
|
|
$currentTeam = $this->currentTeamOrFail($request);
|
|
$this->authorize('bootstrap', [$server, $currentTeam, $cluster]);
|
|
|
|
// Fail fast: the bootstrap job hard-fails on Flux enrollment (after
|
|
// the WireGuard mesh is already built) when no Flux URL is configured.
|
|
if (trim((string) config('coold.flux_url', '')) === '') {
|
|
return response()->json([
|
|
'message' => 'COOLIFY_COOLD_FLUX_URL is not configured, so bootstrapped servers cannot be enrolled into Flux. Set it and retry the bootstrap.',
|
|
], 422);
|
|
}
|
|
|
|
if ($server->last_bootstrapped_at !== null) {
|
|
return response()->json([
|
|
'message' => 'This server is already bootstrapped.',
|
|
], 409);
|
|
}
|
|
|
|
$installedServers = $cluster->servers()
|
|
->with('privateKey')
|
|
->whereNotNull('last_bootstrapped_at')
|
|
->orderBy('name')
|
|
->get();
|
|
$server->load('privateKey');
|
|
$servers = $installedServers->toBase()
|
|
->push($server)
|
|
->unique('id')
|
|
->values();
|
|
|
|
if ($servers->contains(fn (V5Server $server) => ! $server->privateKey instanceof PrivateKey)) {
|
|
return response()->json([
|
|
'message' => 'The new server and every already-bootstrapped server in this cluster must have a private key before extending the cluster.',
|
|
], 422);
|
|
}
|
|
|
|
$claim = DB::transaction(function () use ($cluster, $server, $installedServers): array {
|
|
$clusterServers = $cluster->servers()->lockForUpdate()->get();
|
|
$activeServer = $clusterServers->first(fn (V5Server $candidate): bool => $this->hasActiveBootstrapClaim($candidate));
|
|
|
|
if ($activeServer instanceof V5Server) {
|
|
return ['claimed' => false, 'active_server_id' => $activeServer->id];
|
|
}
|
|
|
|
// Sweep provably dead claims (lost job or killed worker) so retries
|
|
// are possible and the UI reflects reality.
|
|
$clusterServers
|
|
->filter(fn (V5Server $candidate): bool => in_array($candidate->last_bootstrap_status, ['queued', 'running'], true))
|
|
->each(fn (V5Server $candidate) => $candidate->update([
|
|
'last_bootstrap_status' => 'failed',
|
|
'last_bootstrap_output' => 'The previous bootstrap attempt timed out or its worker died. Retry the bootstrap.',
|
|
]));
|
|
|
|
$server->update([
|
|
'last_bootstrap_action' => $installedServers->isEmpty() ? 'bootstrap' : 'extend',
|
|
'last_bootstrap_status' => 'queued',
|
|
'last_bootstrap_output' => "Queued Coolify bootstrap for {$server->name}.",
|
|
'last_bootstrap_ran_at' => now(),
|
|
]);
|
|
|
|
return ['claimed' => true, 'active_server_id' => null];
|
|
});
|
|
|
|
if (! $claim['claimed']) {
|
|
return response()->json([
|
|
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
|
'message' => $claim['active_server_id'] === $server->id
|
|
? 'Bootstrap is already queued or running for this server.'
|
|
: 'Another server bootstrap is already queued or running for this cluster.',
|
|
], 409);
|
|
}
|
|
|
|
V5ClusterUpdated::dispatch($currentTeam->id, $cluster->id);
|
|
V5BootstrapServerJob::dispatch($cluster->id, $server->id);
|
|
|
|
return response()->json([
|
|
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
|
'message' => 'Bootstrap queued.',
|
|
], 202);
|
|
}
|
|
|
|
public function destroy(Request $request, V5Cluster $cluster, V5Server $server): Response|JsonResponse
|
|
{
|
|
$currentTeam = $this->currentTeamOrFail($request);
|
|
$this->authorize('delete', [$server, $currentTeam, $cluster]);
|
|
|
|
if (V5Application::query()->where('server_id', $server->id)->exists()) {
|
|
return response()->json([
|
|
'message' => 'Delete or move applications from this server before deleting it.',
|
|
], 422);
|
|
}
|
|
|
|
$warning = null;
|
|
|
|
if ($server->last_bootstrapped_at !== null) {
|
|
if ($server->isIngress() && $server->status === ServerStatus::Installed->value) {
|
|
try {
|
|
StopCaddyIngress::run($server);
|
|
} catch (\Throwable $exception) {
|
|
report($exception);
|
|
$warning = 'Could not stop the Caddy ingress on the server before deleting it.';
|
|
}
|
|
}
|
|
|
|
if (! RemoveBootstrapMarker::run($server)) {
|
|
$warning = 'Could not clean up the server over SSH. Remove /etc/coolify/v5-node.json manually before re-adding this server.';
|
|
}
|
|
}
|
|
|
|
$server->delete();
|
|
|
|
return response()->json(array_filter([
|
|
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
|
'warning' => $warning,
|
|
]));
|
|
}
|
|
|
|
/**
|
|
* A queued claim is active while the job could still pick it up; a running
|
|
* claim is active until the job timeout (plus margin) has passed. Anything
|
|
* older is provably dead because the job runs with $tries = 1.
|
|
*/
|
|
private function hasActiveBootstrapClaim(V5Server $server): bool
|
|
{
|
|
$ranAt = $server->last_bootstrap_ran_at;
|
|
|
|
return match ($server->last_bootstrap_status) {
|
|
'queued' => $ranAt !== null && $ranAt->gt(now()->subMinutes(15)),
|
|
'running' => $ranAt !== null && $ranAt->gt(now()->subSeconds(V5BootstrapServerJob::TIMEOUT_SECONDS + 300)),
|
|
default => false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array{listen_port: int|null, endpoint: string|null}
|
|
*/
|
|
private function devLimaWireguardOverrides(string $host, int $sshPort): array
|
|
{
|
|
if (! app()->environment(['local', 'development', 'testing']) || $host !== 'host.docker.internal') {
|
|
return ['listen_port' => null, 'endpoint' => null];
|
|
}
|
|
|
|
if ($sshPort < 60001 || $sshPort > 60009) {
|
|
return ['listen_port' => null, 'endpoint' => null];
|
|
}
|
|
|
|
$wireguardPort = $sshPort - 8180;
|
|
|
|
return [
|
|
'listen_port' => $wireguardPort,
|
|
'endpoint' => "host.lima.internal:{$wireguardPort}",
|
|
];
|
|
}
|
|
|
|
private function clusterServerCapacity(V5Cluster $cluster): ?int
|
|
{
|
|
$namespaceCount = max(1, count($cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES));
|
|
|
|
[, $poolPrefix] = array_pad(explode('/', (string) $cluster->container_network_pool, 2), 2, null);
|
|
$containerPrefix = (int) $cluster->container_network_prefix;
|
|
|
|
if (! is_string($poolPrefix) || ! ctype_digit($poolPrefix) || $containerPrefix < (int) $poolPrefix || $containerPrefix > 32) {
|
|
return null;
|
|
}
|
|
|
|
$containerCapacity = intdiv(2 ** ($containerPrefix - (int) $poolPrefix), $namespaceCount);
|
|
|
|
[, $managementPrefix] = array_pad(explode('/', (string) $cluster->wireguard_management_pool, 2), 2, null);
|
|
$managementCapacity = is_string($managementPrefix) && ctype_digit($managementPrefix) && (int) $managementPrefix <= 30
|
|
? (2 ** (32 - (int) $managementPrefix)) - 2
|
|
: null;
|
|
|
|
return $managementCapacity === null ? $containerCapacity : min($containerCapacity, $managementCapacity);
|
|
}
|
|
|
|
private function noControlCharactersRule(): \Closure
|
|
{
|
|
return function (string $attribute, mixed $value, \Closure $fail): void {
|
|
if (! is_string($value)) {
|
|
return;
|
|
}
|
|
|
|
if (preg_match('/[\x00-\x1F\x7F]/', $value) === 1) {
|
|
$fail('The :attribute contains invalid control characters.');
|
|
}
|
|
};
|
|
}
|
|
|
|
private function hostPortRule(): \Closure
|
|
{
|
|
return function (string $attribute, mixed $value, \Closure $fail): void {
|
|
if ($value === null || $value === '') {
|
|
return;
|
|
}
|
|
|
|
if (! is_string($value)) {
|
|
$fail('The :attribute must be in host:port format.');
|
|
|
|
return;
|
|
}
|
|
|
|
$value = trim($value);
|
|
|
|
if (preg_match('/^\[(?<host>.+)]:(?<port>\d+)$/', $value, $matches) === 1) {
|
|
$host = trim((string) $matches['host']);
|
|
$port = trim((string) $matches['port']);
|
|
} else {
|
|
$separatorPosition = strrpos($value, ':');
|
|
|
|
if ($separatorPosition === false) {
|
|
$fail('The :attribute must be in host:port format.');
|
|
|
|
return;
|
|
}
|
|
|
|
$host = trim(substr($value, 0, $separatorPosition));
|
|
$port = trim(substr($value, $separatorPosition + 1));
|
|
|
|
if (str_contains($host, ':')) {
|
|
$fail('The :attribute must use [ipv6]:port format for IPv6 addresses.');
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
if ($host === '' || $port === '' || ! ctype_digit($port) || (int) $port < 1 || (int) $port > 65535) {
|
|
$fail('The :attribute must be in host:port format.');
|
|
|
|
return;
|
|
}
|
|
|
|
$failed = false;
|
|
(new ValidServerIp)->validate($attribute, $host, function () use (&$failed): void {
|
|
$failed = true;
|
|
});
|
|
|
|
if ($failed) {
|
|
$fail('The :attribute must be in host:port format.');
|
|
}
|
|
};
|
|
}
|
|
}
|