mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 16:14:42 +00:00
chore(v5): archive V5 implementation and remove runtime integration
Move V5 source, migrations, UI, scripts, and tests into documentation, then remove V5 routes, models, jobs, configuration, dependencies, and application hooks.
This commit is contained in:
1 parent
1440d35750
commit
76030a30d4
237 files changed
+288
-7079
No files matched your search
@@ -0,0 +1,10 @@
|
||||
# Archived V5 Implementation
|
||||
|
||||
This directory preserves every file removed from the previous executable V5
|
||||
implementation, using its original repository path beneath this directory.
|
||||
Files have a `.txt` suffix so framework, build, test, and runtime discovery
|
||||
cannot load them.
|
||||
|
||||
The previous migrations and UI source have dedicated archives in
|
||||
`docs/v5/migrations/` and `docs/v5/ui/`. This directory contains the remaining
|
||||
backend, configuration, development tooling, and test sources.
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Application;
|
||||
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Enums\V5\ContainerState;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\V5\Application;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class DeployNginxApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function __construct(private readonly FluxClient $fluxClient) {}
|
||||
|
||||
public function handle(Application $application): Application
|
||||
{
|
||||
$application->loadMissing('server');
|
||||
$server = $application->server;
|
||||
|
||||
if ($server === null) {
|
||||
return $this->markFailed($application, 'No server is attached to this application.');
|
||||
}
|
||||
|
||||
if ($server->status !== ServerStatus::Installed->value || $server->last_bootstrapped_at === null) {
|
||||
return $this->markFailed($application, "Bootstrap server {$server->name} before deploying to it.");
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
return $this->markFailed($application, 'No Flux host ID is available for this server.');
|
||||
}
|
||||
|
||||
$containerId = null;
|
||||
|
||||
try {
|
||||
$this->fluxClient->pullImage($hostId, $application->image);
|
||||
$containerId = $this->fluxClient->createContainer($hostId, $this->containerSpec($application));
|
||||
|
||||
// Persist the runtime id the instant the container exists, before
|
||||
// start/inspect. A worker SIGKILL at the job timeout would otherwise
|
||||
// orphan a created container whose id only lived in this local var,
|
||||
// leaving failed()/reconcile unable to find and clean it by id.
|
||||
$application->update([
|
||||
'status' => ApplicationStatus::Created->value,
|
||||
'status_message' => 'Container created.',
|
||||
'runtime_container_id' => $containerId,
|
||||
]);
|
||||
|
||||
$this->fluxClient->startContainer($hostId, $containerId);
|
||||
$inspect = $this->fluxClient->inspectContainer($hostId, $containerId);
|
||||
|
||||
if (! $this->isContainerRunning($inspect)) {
|
||||
$this->cleanUpContainer($application, $hostId, $containerId);
|
||||
|
||||
return $this->markFailed($application, 'Container did not stay running.');
|
||||
}
|
||||
|
||||
$application->update([
|
||||
'status' => ApplicationStatus::Running->value,
|
||||
'status_message' => 'Container started.',
|
||||
'runtime_container_id' => $containerId,
|
||||
]);
|
||||
|
||||
return $application->refresh()->load('server');
|
||||
} catch (\Throwable $e) {
|
||||
if (is_string($containerId) && $containerId !== '') {
|
||||
$this->cleanUpContainer($application, $hostId, $containerId);
|
||||
}
|
||||
|
||||
return $this->markFailed($application, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort compensation for a failed deploy: stop and force-remove the
|
||||
* container this run created so it is never left orphaned on the node, then
|
||||
* null the runtime id we persisted right after create so a cleaned-up
|
||||
* failure never leaves a dangling id that reconcile would try to reap.
|
||||
* Cleanup failures only log a warning and never mask the original error.
|
||||
*/
|
||||
private function cleanUpContainer(Application $application, string $hostId, string $containerId): void
|
||||
{
|
||||
try {
|
||||
$this->fluxClient->stopContainer($hostId, $containerId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Could not stop the container created by a failed v5 deploy.', [
|
||||
'application_id' => $application->getKey(),
|
||||
'container_id' => $containerId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->fluxClient->removeContainer($hostId, $containerId, force: true);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Could not remove the container created by a failed v5 deploy.', [
|
||||
'application_id' => $application->getKey(),
|
||||
'container_id' => $containerId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($application->runtime_container_id === $containerId) {
|
||||
$application->update(['runtime_container_id' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function containerSpec(Application $application): array
|
||||
{
|
||||
$network = $this->meshNetwork($application);
|
||||
$containerName = $application->container_name;
|
||||
|
||||
return [
|
||||
'name' => $containerName,
|
||||
'image' => $application->image,
|
||||
'networks' => [$network],
|
||||
'network_aliases' => [$containerName],
|
||||
'dns_search' => [$this->meshDnsSearchDomain($application)],
|
||||
'restart_policy' => 'unless-stopped',
|
||||
];
|
||||
}
|
||||
|
||||
private function meshNetwork(Application $application): string
|
||||
{
|
||||
$namespace = $application->mesh_namespace ?: 'default';
|
||||
|
||||
return "coolify-{$namespace}-mesh";
|
||||
}
|
||||
|
||||
private function meshDnsSearchDomain(Application $application): string
|
||||
{
|
||||
$namespace = $application->mesh_namespace ?: 'default';
|
||||
|
||||
return "{$namespace}.coolify.internal";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $inspect
|
||||
*/
|
||||
private function isContainerRunning(array $inspect): bool
|
||||
{
|
||||
$state = $inspect['State'] ?? [];
|
||||
|
||||
if (is_array($state) && ($state['Running'] ?? null) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_string($inspect['state'] ?? null) && $inspect['state'] === ContainerState::Running->value;
|
||||
}
|
||||
|
||||
private function markFailed(Application $application, string $message): Application
|
||||
{
|
||||
$application->update([
|
||||
'status' => ApplicationStatus::Failed->value,
|
||||
'status_message' => str($message)->limit(10000)->toString(),
|
||||
]);
|
||||
|
||||
return $application->refresh()->load('server');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Application;
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\V5\Application;
|
||||
use Illuminate\Contracts\Process\ProcessResult;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class DestroyNginxApplication
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Application $application): ?string
|
||||
{
|
||||
$application->loadMissing('server.privateKey');
|
||||
$server = $application->server;
|
||||
|
||||
if ($server === null || ! $server->privateKey instanceof PrivateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$keyLocation = $this->writeTemporaryPrivateKey($server->privateKey);
|
||||
|
||||
try {
|
||||
$result = Process::timeout(120)->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}",
|
||||
$this->remoteCommand($application),
|
||||
]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
return $this->processOutput($result);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
return $e->getMessage();
|
||||
} finally {
|
||||
@unlink($keyLocation);
|
||||
}
|
||||
}
|
||||
|
||||
private function remoteCommand(Application $application): string
|
||||
{
|
||||
$containerName = escapeshellarg($application->container_name);
|
||||
|
||||
return implode(PHP_EOL, [
|
||||
'set -e',
|
||||
'if [ "$(id -u)" = "0" ]; then podman=podman; else podman="sudo -n podman"; fi',
|
||||
"\$podman rm -f {$containerName} >/dev/null 2>&1 || true",
|
||||
]);
|
||||
}
|
||||
|
||||
private function processOutput(ProcessResult $result): string
|
||||
{
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
|
||||
return $output !== '' ? $output : 'Could not delete nginx container.';
|
||||
}
|
||||
|
||||
private function writeTemporaryPrivateKey(PrivateKey $privateKey): string
|
||||
{
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
if (! is_dir($keyDirectory)) {
|
||||
mkdir($keyDirectory, 0700, true);
|
||||
}
|
||||
|
||||
$keyLocation = tempnam($keyDirectory, 'v5_nginx_destroy_key_');
|
||||
if ($keyLocation === false) {
|
||||
throw new \RuntimeException('Could not create a temporary SSH key file.');
|
||||
}
|
||||
|
||||
file_put_contents($keyLocation, $privateKey->private_key);
|
||||
chmod($keyLocation, 0600);
|
||||
|
||||
return $keyLocation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Flux;
|
||||
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Enums\V5\ContainerState;
|
||||
use App\Enums\V5\IngressStatus;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ContainerStatus;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Support\V5\StatusObservation;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class ApplyFluxResourceStatusUpdate
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function handle(array $payload): ?Model
|
||||
{
|
||||
$resourceType = strtolower((string) data_get($payload, 'resource_type', data_get($payload, 'type', '')));
|
||||
|
||||
$containerStatus = $resourceType === 'container' ? $this->upsertContainerStatus($payload) : null;
|
||||
|
||||
if ($this->isCaddyIngressStatusUpdate($payload, $resourceType)) {
|
||||
return $this->updateCaddyIngress($payload) ?? $containerStatus;
|
||||
}
|
||||
|
||||
if (in_array($resourceType, ['server', 'node', 'host'], true)) {
|
||||
return $this->updateServer($payload);
|
||||
}
|
||||
|
||||
return $this->updateApplication($payload) ?? $containerStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function upsertContainerStatus(array $payload): ?ContainerStatus
|
||||
{
|
||||
$status = $this->status($payload, ContainerState::class);
|
||||
$containerId = $this->stringValue($payload, 'container_id') ?? $this->stringValue($payload, 'runtime_container_id');
|
||||
$server = $this->findServer($payload);
|
||||
|
||||
if ($status === null || $containerId === null || ! $server instanceof V5Server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$observedAt = $this->observedAt($payload);
|
||||
$existing = ContainerStatus::query()
|
||||
->where('server_id', $server->id)
|
||||
->where('container_id', $containerId)
|
||||
->first();
|
||||
|
||||
if ($this->isStaleObservation($observedAt, $existing?->status_observed_at, 'container status', [
|
||||
'server_id' => $server->id,
|
||||
'container_id' => $containerId,
|
||||
])) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'team_id' => $server->team_id,
|
||||
'container_name' => $this->stringValue($payload, 'container_name') ?? $this->stringValue($payload, 'name'),
|
||||
'image' => $this->stringValue($payload, 'image'),
|
||||
'status' => $status,
|
||||
'status_message' => $this->statusMessage($payload, 'Container state received from coold.'),
|
||||
'last_seen_at' => now(),
|
||||
];
|
||||
|
||||
if ($observedAt !== null) {
|
||||
$attributes['status_observed_at'] = $observedAt;
|
||||
}
|
||||
|
||||
ContainerStatus::query()->updateOrCreate([
|
||||
'server_id' => $server->id,
|
||||
'container_id' => $containerId,
|
||||
], $attributes);
|
||||
|
||||
return ContainerStatus::query()
|
||||
->where('server_id', $server->id)
|
||||
->where('container_id', $containerId)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function updateApplication(array $payload): ?V5Application
|
||||
{
|
||||
$status = $this->status($payload, ApplicationStatus::class);
|
||||
|
||||
if ($status === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$application = $this->findApplication($payload);
|
||||
|
||||
if (! $application instanceof V5Application) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$observedAt = $this->observedAt($payload);
|
||||
|
||||
if ($this->isStaleObservation($observedAt, $application->status_observed_at, 'application status', [
|
||||
'application_id' => $application->id,
|
||||
])) {
|
||||
return $application;
|
||||
}
|
||||
|
||||
$payloadContainerId = $this->stringValue($payload, 'runtime_container_id')
|
||||
?? $this->stringValue($payload, 'container_id');
|
||||
|
||||
// Payloads may carry no timestamp, so the container id remains an
|
||||
// ordering signal as a second layer: an update for a superseded
|
||||
// container is stale and must not overwrite the current one's state.
|
||||
if (
|
||||
$payloadContainerId !== null
|
||||
&& $application->runtime_container_id !== null
|
||||
&& $payloadContainerId !== $application->runtime_container_id
|
||||
) {
|
||||
return $application;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'status' => $status,
|
||||
'status_message' => $this->statusMessage($payload, 'Status updated by flux.'),
|
||||
'runtime_container_id' => $payloadContainerId ?? $application->runtime_container_id,
|
||||
];
|
||||
|
||||
if ($observedAt !== null) {
|
||||
$attributes['status_observed_at'] = $observedAt;
|
||||
}
|
||||
|
||||
$application->update($attributes);
|
||||
|
||||
return $application->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function updateServer(array $payload): ?V5Server
|
||||
{
|
||||
$status = $this->status($payload, ServerStatus::class);
|
||||
|
||||
if ($status === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$server = $this->findServer($payload);
|
||||
|
||||
if (! $server instanceof V5Server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$observedAt = $this->observedAt($payload);
|
||||
|
||||
if ($this->isStaleObservation($observedAt, $server->status_observed_at, 'server status', [
|
||||
'server_id' => $server->id,
|
||||
])) {
|
||||
return $server;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'status' => $status,
|
||||
'last_status_check' => 'flux',
|
||||
'last_status_output' => $this->statusMessage($payload, 'Status updated by flux.'),
|
||||
'last_status_checked_at' => now(),
|
||||
];
|
||||
|
||||
if ($observedAt !== null) {
|
||||
$attributes['status_observed_at'] = $observedAt;
|
||||
}
|
||||
|
||||
$server->update($attributes);
|
||||
|
||||
return $server->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* The ingress state shares the server row but describes a different
|
||||
* resource, so it deliberately does not read or write the server's
|
||||
* `status_observed_at` watermark.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function updateCaddyIngress(array $payload): ?V5Server
|
||||
{
|
||||
$status = $this->status($payload, IngressStatus::class);
|
||||
|
||||
if ($status === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$server = $this->findServer($payload);
|
||||
|
||||
if (! $server instanceof V5Server || ! $server->isIngress()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$server->update([
|
||||
'ingress_type' => 'caddy',
|
||||
'ingress_status' => $status,
|
||||
'last_status_check' => 'flux',
|
||||
'last_status_output' => $this->statusMessage($payload, 'Status updated by flux.'),
|
||||
'last_status_checked_at' => now(),
|
||||
]);
|
||||
|
||||
return $server->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function findApplication(array $payload): ?V5Application
|
||||
{
|
||||
$server = $this->findServer($payload);
|
||||
|
||||
if (! $server instanceof V5Server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = V5Application::query()
|
||||
->with('server')
|
||||
->where('server_id', $server->id)
|
||||
->where('team_id', $server->team_id);
|
||||
|
||||
$applicationUuid = $this->stringValue($payload, 'application_uuid') ?? $this->stringValue($payload, 'resource_uuid');
|
||||
|
||||
if ($applicationUuid !== null) {
|
||||
return $query->where('uuid', $applicationUuid)->first();
|
||||
}
|
||||
|
||||
$containerName = $this->stringValue($payload, 'container_name') ?? $this->stringValue($payload, 'name');
|
||||
|
||||
if ($containerName !== null) {
|
||||
return $query->where('container_name', $containerName)->first();
|
||||
}
|
||||
|
||||
$containerId = $this->stringValue($payload, 'runtime_container_id') ?? $this->stringValue($payload, 'container_id');
|
||||
|
||||
if ($containerId !== null) {
|
||||
return $query->where('runtime_container_id', $containerId)->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function isCaddyIngressStatusUpdate(array $payload, string $resourceType): bool
|
||||
{
|
||||
if (in_array($resourceType, ['caddy_ingress', 'caddy-ingress'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->stringValue($payload, 'container_name') === 'coolify-v5-caddy'
|
||||
|| $this->stringValue($payload, 'name') === 'coolify-v5-caddy';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function findServer(array $payload): ?V5Server
|
||||
{
|
||||
$serverUuid = $this->stringValue($payload, 'server_uuid') ?? $this->stringValue($payload, 'host_server_uuid');
|
||||
|
||||
if ($serverUuid !== null) {
|
||||
return V5Server::query()->where('uuid', $serverUuid)->first();
|
||||
}
|
||||
|
||||
$hostId = $this->stringValue($payload, 'host_id')
|
||||
?? $this->stringValue($payload, 'node_id')
|
||||
?? $this->stringValue($payload, 'server_host');
|
||||
|
||||
if ($hostId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$matches = V5Server::query()
|
||||
->where('uuid', $hostId)
|
||||
->limit(2)
|
||||
->get();
|
||||
|
||||
if ($matches->count() > 1) {
|
||||
Log::warning('Dropping flux resource status update: host id matches multiple v5 servers.', [
|
||||
'host_id' => $hostId,
|
||||
'server_ids' => $matches->pluck('id')->all(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $matches->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the raw payload status onto the given status enum. Unknown values
|
||||
* are never written to the database: they fall back to the enum's
|
||||
* Unknown case and are logged.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @param class-string<ApplicationStatus|ContainerState|IngressStatus|ServerStatus> $enumClass
|
||||
*/
|
||||
private function status(array $payload, string $enumClass): ?string
|
||||
{
|
||||
$raw = $this->stringValue($payload, 'status') ?? $this->stringValue($payload, 'state');
|
||||
|
||||
return StatusObservation::normalize($raw, $enumClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function observedAt(array $payload): ?CarbonInterface
|
||||
{
|
||||
$observedAt = $this->stringValue($payload, 'observed_at');
|
||||
|
||||
if ($observedAt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rescue(fn (): CarbonImmutable => CarbonImmutable::parse($observedAt), null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* A payload that carries an observation timestamp older than the one
|
||||
* already persisted is stale (delivered out of order) and must not
|
||||
* clobber the newer state.
|
||||
*
|
||||
* @param array<string, mixed> $logContext
|
||||
*/
|
||||
private function isStaleObservation(?CarbonInterface $observedAt, ?CarbonInterface $currentObservedAt, string $context, array $logContext): bool
|
||||
{
|
||||
return StatusObservation::isStale($observedAt, $currentObservedAt, $context, $logContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function statusMessage(array $payload, string $fallback): string
|
||||
{
|
||||
return $this->stringValue($payload, 'status_message')
|
||||
?? $this->stringValue($payload, 'message')
|
||||
?? $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function stringValue(array $payload, string $key): ?string
|
||||
{
|
||||
$value = data_get($payload, $key);
|
||||
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Proxy;
|
||||
|
||||
use App\Models\V5\Application;
|
||||
use App\Models\V5\ApplicationDomain;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class GenerateCaddyIngressConfiguration
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* Strict RFC 1123 hostname: dot-separated alphanumeric labels with inner
|
||||
* hyphens, max 253 characters. Anchored with \A/\z (never $) so values
|
||||
* containing newlines, braces, quotes, whitespace, or control characters
|
||||
* can never inject extra directives into the generated Caddyfile.
|
||||
*/
|
||||
private const HOSTNAME_PATTERN = '/\A(?=.{1,253}\z)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*\z/i';
|
||||
|
||||
/**
|
||||
* @param Collection<int, Application>|null $applications
|
||||
* @return array{compose: string, caddyfile: string, apps: array<int, array{name: string, caddyfile: string}>}
|
||||
*/
|
||||
public function handle(?Collection $applications = null): array
|
||||
{
|
||||
return [
|
||||
'compose' => $this->compose(),
|
||||
'caddyfile' => $this->rootCaddyfile(),
|
||||
'apps' => $this->appCaddyfiles($applications ?? collect()),
|
||||
];
|
||||
}
|
||||
|
||||
private function compose(): string
|
||||
{
|
||||
return Yaml::dump([
|
||||
'services' => [
|
||||
'caddy' => [
|
||||
'image' => 'docker.io/library/caddy:2-alpine',
|
||||
'container_name' => 'coolify-v5-caddy',
|
||||
'restart' => 'unless-stopped',
|
||||
'ports' => [
|
||||
'80:80',
|
||||
],
|
||||
'volumes' => [
|
||||
'./Caddyfile:/etc/caddy/Caddyfile:ro',
|
||||
'./apps:/etc/caddy/apps:ro',
|
||||
'./data:/data',
|
||||
'./config:/config',
|
||||
],
|
||||
],
|
||||
],
|
||||
], 8, 2);
|
||||
}
|
||||
|
||||
private function rootCaddyfile(): string
|
||||
{
|
||||
return <<<'CADDY'
|
||||
:80 {
|
||||
respond /coolify-health 200
|
||||
respond 404
|
||||
}
|
||||
|
||||
import apps/*.caddy
|
||||
CADDY;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Application> $applications
|
||||
* @return array<int, array{name: string, caddyfile: string}>
|
||||
*/
|
||||
private function appCaddyfiles(Collection $applications): array
|
||||
{
|
||||
return $applications
|
||||
->each(fn (Application $application) => $application->loadMissing('domains'))
|
||||
->map(fn (Application $application) => [
|
||||
'name' => $this->appFileName($application),
|
||||
'caddyfile' => $this->applicationCaddyfile($application),
|
||||
])
|
||||
->filter(fn (array $file) => $file['caddyfile'] !== '')
|
||||
->sortBy('name')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function applicationCaddyfile(Application $application): string
|
||||
{
|
||||
if (! $application->ingress_enabled || ! $application->internal_port) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $application->domains
|
||||
->map(fn (ApplicationDomain $domain) => $this->applicationRoute($application, $domain))
|
||||
->filter()
|
||||
->sort()
|
||||
->implode("\n\n");
|
||||
}
|
||||
|
||||
private function applicationRoute(Application $application, ApplicationDomain $domain): ?string
|
||||
{
|
||||
if ($domain->domain === null || $domain->domain === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$namespace = $application->mesh_namespace ?: 'default';
|
||||
$internalPort = (int) $application->internal_port;
|
||||
|
||||
if (! $this->isSafeHostname($domain->domain)) {
|
||||
Log::warning('Skipping a caddy ingress route with an unsafe domain.', [
|
||||
'application_id' => $application->getKey(),
|
||||
'domain' => $domain->domain,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->isSafeHostname($application->container_name) || ! $this->isSafeHostname($namespace)) {
|
||||
Log::warning('Skipping a caddy ingress route with an unsafe container name or namespace.', [
|
||||
'application_id' => $application->getKey(),
|
||||
'container_name' => $application->container_name,
|
||||
'namespace' => $namespace,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($internalPort < 1 || $internalPort > 65535) {
|
||||
Log::warning('Skipping a caddy ingress route with an out-of-range internal port.', [
|
||||
'application_id' => $application->getKey(),
|
||||
'internal_port' => $application->internal_port,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$upstream = "{$application->container_name}.{$namespace}.coolify.internal:{$internalPort}";
|
||||
|
||||
return implode("\n", [
|
||||
"http://{$domain->domain} {",
|
||||
" reverse_proxy {$upstream}",
|
||||
'}',
|
||||
]);
|
||||
}
|
||||
|
||||
private function isSafeHostname(mixed $value): bool
|
||||
{
|
||||
return is_string($value) && preg_match(self::HOSTNAME_PATTERN, $value) === 1;
|
||||
}
|
||||
|
||||
private function appFileName(Application $application): string
|
||||
{
|
||||
return 'app_'.$application->getKey();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Proxy;
|
||||
|
||||
use App\Exceptions\V5\UnsupportedCooldVerb;
|
||||
use App\Models\V5\Application;
|
||||
use App\Models\V5\Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StartCaddyIngress
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
private const FIREWALL_PORTS = [80];
|
||||
|
||||
public function __construct(private readonly FluxClient $fluxClient) {}
|
||||
|
||||
public function handle(Server $server): string
|
||||
{
|
||||
if (! $server->isIngress()) {
|
||||
return 'Server is not an ingress server.';
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
throw new \RuntimeException('Server is missing its Flux host id.');
|
||||
}
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run($this->applications($server));
|
||||
$output = $this->fluxClient->applyIngress($hostId, 'caddy', $configuration['caddyfile'], $this->ingressApps($configuration['apps']));
|
||||
|
||||
$firewallWarning = null;
|
||||
|
||||
foreach (self::FIREWALL_PORTS as $port) {
|
||||
try {
|
||||
$this->fluxClient->applyFirewallRule($hostId, [
|
||||
'id' => "v5-caddy-ingress:{$port}",
|
||||
'namespace' => 'default',
|
||||
'src' => '0.0.0.0/0',
|
||||
'dst' => 'coolify-v5-caddy',
|
||||
'proto' => 'tcp',
|
||||
'port' => $port,
|
||||
]);
|
||||
} catch (UnsupportedCooldVerb $exception) {
|
||||
$firewallWarning = "Caddy ingress is running, but this node's coold does not support {$exception->verb}, so the managed firewall was not updated for port {$port}.";
|
||||
Log::warning('V5 caddy ingress firewall rule skipped: coold verb unsupported', [
|
||||
'server_id' => $server->id,
|
||||
'port' => $port,
|
||||
'verb' => $exception->verb,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($server->exists) {
|
||||
$server->update([
|
||||
'ingress_type' => 'caddy',
|
||||
'ingress_status' => 'running',
|
||||
...($firewallWarning === null ? [] : [
|
||||
'last_status_check' => 'flux',
|
||||
'last_status_output' => $firewallWarning,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name: string, caddyfile: string}> $apps
|
||||
* @return array<int, array{name: string, config: string}>
|
||||
*/
|
||||
private function ingressApps(array $apps): array
|
||||
{
|
||||
return array_map(
|
||||
fn (array $app): array => [
|
||||
'name' => $app['name'],
|
||||
'config' => $app['caddyfile'],
|
||||
],
|
||||
$apps
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Application>
|
||||
*/
|
||||
private function applications(Server $server): Collection
|
||||
{
|
||||
if (! $server->exists) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Application::query()
|
||||
->where('team_id', $server->team_id)
|
||||
->where('server_id', $server->id)
|
||||
->with('domains')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Proxy;
|
||||
|
||||
use App\Enums\V5\IngressStatus;
|
||||
use App\Exceptions\V5\UnsupportedCooldVerb;
|
||||
use App\Models\V5\Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class StopCaddyIngress
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
private const FIREWALL_PORTS = [80];
|
||||
|
||||
public function __construct(private readonly FluxClient $fluxClient) {}
|
||||
|
||||
public function handle(Server $server): string
|
||||
{
|
||||
if (! $server->isIngress() && $server->ingress_type === null) {
|
||||
return 'Server is not an ingress server.';
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
throw new \RuntimeException('Server is missing its Flux host id.');
|
||||
}
|
||||
|
||||
// Revoke first: if stopping the container fails the allow rules must not
|
||||
// stay orphaned on the host.
|
||||
foreach (self::FIREWALL_PORTS as $port) {
|
||||
$this->revokeFirewallRuleIfPresent($hostId, "v5-caddy-ingress:{$port}");
|
||||
}
|
||||
|
||||
$output = $this->fluxClient->stopIngress($hostId, 'caddy');
|
||||
|
||||
if ($server->exists) {
|
||||
$server->update(['ingress_status' => IngressStatus::Exited->value]);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
private function revokeFirewallRuleIfPresent(string $hostId, string $ruleId): void
|
||||
{
|
||||
try {
|
||||
$this->fluxClient->revokeFirewallRule($hostId, $ruleId);
|
||||
} catch (UnsupportedCooldVerb $exception) {
|
||||
Log::warning('V5 caddy ingress firewall revoke skipped: coold verb unsupported', [
|
||||
'host_id' => $hostId,
|
||||
'rule_id' => $ruleId,
|
||||
'verb' => $exception->verb,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
} catch (\RuntimeException $exception) {
|
||||
if (! str_contains(Str::lower($exception->getMessage()), 'not found')) {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Server;
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\V5\Server;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class PushHostAgentToken
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* Best-effort SSH push of a freshly minted host JWT to the on-host jwt path.
|
||||
*
|
||||
* coold re-reads the JWT file on every reconnect and flux drops the stream
|
||||
* at the token's exp, so overwriting the file in place is enough for the
|
||||
* next reconnect to pick up the new token — coold is intentionally NOT
|
||||
* restarted here (a restart would force an unnecessary disconnect of a
|
||||
* stream that is still valid on the current token).
|
||||
*
|
||||
* Mirrors V5BootstrapServerJob::enrollCooldIntoFlux for the write mechanics
|
||||
* (printf %s <token> | sudo tee <path>; chmod 600) and RemoveBootstrapMarker
|
||||
* for the SSH/temp-key mechanics. Returns whether the write succeeded;
|
||||
* every failure path (missing key, SSH error, exception) resolves to false
|
||||
* and always cleans up the temporary key file.
|
||||
*/
|
||||
public function handle(Server $server, string $token): bool
|
||||
{
|
||||
$server->loadMissing('privateKey');
|
||||
|
||||
if (! $server->privateKey instanceof PrivateKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$jwtPath = trim((string) config('coold.flux_host_jwt_path', '/etc/coolify/host-jwt'));
|
||||
|
||||
if ($jwtPath === '') {
|
||||
$jwtPath = '/etc/coolify/host-jwt';
|
||||
}
|
||||
|
||||
$jwtPath = str_replace(["\r", "\n"], '', $jwtPath);
|
||||
$token = str_replace(["\r", "\n"], '', $token);
|
||||
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
if (! is_dir($keyDirectory)) {
|
||||
mkdir($keyDirectory, 0700, true);
|
||||
}
|
||||
|
||||
$keyLocation = tempnam($keyDirectory, 'v5_ssh_key_');
|
||||
if ($keyLocation === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
file_put_contents($keyLocation, $server->privateKey->private_key);
|
||||
chmod($keyLocation, 0600);
|
||||
|
||||
$tokenArgument = escapeshellarg($token);
|
||||
$jwtPathArgument = $this->shellPathArg($jwtPath);
|
||||
$script = <<<SH
|
||||
set -e
|
||||
SUDO=''
|
||||
if [ "\$(id -u)" != "0" ]; then SUDO='sudo'; fi
|
||||
\$SUDO mkdir -p /etc/coolify
|
||||
printf %s {$tokenArgument} | \$SUDO tee {$jwtPathArgument} >/dev/null
|
||||
\$SUDO chmod 600 {$jwtPathArgument}
|
||||
SH;
|
||||
|
||||
try {
|
||||
$result = Process::timeout(30)->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}",
|
||||
$script,
|
||||
]);
|
||||
|
||||
return $result->successful();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
} finally {
|
||||
@unlink($keyLocation);
|
||||
}
|
||||
}
|
||||
|
||||
private function shellPathArg(string $value): string
|
||||
{
|
||||
if (preg_match('/^[A-Za-z0-9_\/:.,@%+=-]+$/', $value) === 1) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return escapeshellarg($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Server;
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\V5\Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class RemoveBootstrapMarker
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* Best-effort removal of the on-host bootstrap identity (marker, host JWT and
|
||||
* Flux drop-in) so a re-added server can never silently adopt stale state.
|
||||
*
|
||||
* The host token jti is revoked first (a local DB write plus a best-effort
|
||||
* push to the flux revocation store) so a captured or pre-copied token is
|
||||
* recorded revoked even when the host is unreachable — see
|
||||
* AgentTokenIssuer::revoke.
|
||||
*/
|
||||
public function handle(Server $server): bool
|
||||
{
|
||||
app(AgentTokenIssuer::class)->revoke($server);
|
||||
|
||||
$server->loadMissing('privateKey');
|
||||
|
||||
if (! $server->privateKey instanceof PrivateKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
if (! is_dir($keyDirectory)) {
|
||||
mkdir($keyDirectory, 0700, true);
|
||||
}
|
||||
|
||||
$keyLocation = tempnam($keyDirectory, 'v5_ssh_key_');
|
||||
if ($keyLocation === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
file_put_contents($keyLocation, $server->privateKey->private_key);
|
||||
chmod($keyLocation, 0600);
|
||||
|
||||
$script = implode("\n", [
|
||||
"SUDO=''",
|
||||
'if [ "$(id -u)" != "0" ]; then SUDO=\'sudo\'; fi',
|
||||
'$SUDO rm -f /etc/coolify/v5-node.json /etc/coolify/host-jwt /etc/systemd/system/coold.service.d/10-flux.conf',
|
||||
]);
|
||||
|
||||
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}",
|
||||
$script,
|
||||
]);
|
||||
|
||||
return $result->successful();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
} finally {
|
||||
@unlink($keyLocation);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\V5\Server;
|
||||
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Cluster;
|
||||
use App\Models\V5\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
/**
|
||||
* Registers local Lima development VMs (provisioned by scripts/dev.sh) as
|
||||
* cluster servers. They are intentionally seeded as Installed with
|
||||
* last_bootstrapped_at already set but has_coold=false, so they skip the real
|
||||
* bootstrap flow by design: V5BootstrapServerJob early-returns on a non-null
|
||||
* last_bootstrapped_at, and V5ReconcileServersJob ignores them until
|
||||
* something marks has_coold=true.
|
||||
*/
|
||||
class SyncDevLimaServers
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* @param array<int, array{
|
||||
* name: string,
|
||||
* host: string,
|
||||
* ssh_user: string,
|
||||
* ssh_port: int,
|
||||
* wireguard_management_ip?: ?string,
|
||||
* wireguard_listen_port_override?: ?int,
|
||||
* wireguard_endpoint_override?: ?string
|
||||
* }> $servers
|
||||
*/
|
||||
public function handle(
|
||||
Team $team,
|
||||
User $user,
|
||||
?PrivateKey $privateKey,
|
||||
string $clusterName,
|
||||
array $servers,
|
||||
): Cluster {
|
||||
$cluster = Cluster::query()->updateOrCreate([
|
||||
'team_id' => $team->id,
|
||||
'name' => $clusterName,
|
||||
], [
|
||||
'created_by_user_id' => $user->id,
|
||||
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
|
||||
]);
|
||||
|
||||
foreach ($servers as $server) {
|
||||
$wireguardManagementIp = $server['wireguard_management_ip'] ?? null;
|
||||
$values = [
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey?->id,
|
||||
'host' => $server['host'],
|
||||
'ssh_user' => $server['ssh_user'],
|
||||
'ssh_port' => $server['ssh_port'],
|
||||
'status' => ServerStatus::Installed->value,
|
||||
'has_coold' => false,
|
||||
'is_ingress' => false,
|
||||
'builder_enabled' => false,
|
||||
'builder_capacity' => 0,
|
||||
'node_address' => $wireguardManagementIp ?: $server['host'],
|
||||
'wireguard_management_ip' => $wireguardManagementIp,
|
||||
'last_bootstrapped_at' => now(),
|
||||
];
|
||||
|
||||
if (array_key_exists('wireguard_listen_port_override', $server)) {
|
||||
$values['wireguard_listen_port_override'] = $server['wireguard_listen_port_override'];
|
||||
}
|
||||
|
||||
if (array_key_exists('wireguard_endpoint_override', $server)) {
|
||||
$values['wireguard_endpoint_override'] = $server['wireguard_endpoint_override'];
|
||||
}
|
||||
|
||||
Server::query()->updateOrCreate([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'name' => $server['name'],
|
||||
], $values);
|
||||
}
|
||||
|
||||
return $cluster->refresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Support\V5\V5Feature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class FluxDev extends Command
|
||||
{
|
||||
protected $signature = 'flux:dev
|
||||
{host_id=coold-dev : Stable coold host id}
|
||||
{--caps= : Comma-separated host capabilities}
|
||||
{--ttl=3600 : Token lifetime in seconds}
|
||||
{--output= : Optional path to write the token with 0600 permissions}';
|
||||
|
||||
protected $description = 'Run Flux development helpers.';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function defaultCapabilities(): array
|
||||
{
|
||||
return [
|
||||
'host-agent:dev',
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(AgentTokenIssuer $agentTokenIssuer): int
|
||||
{
|
||||
if (! V5Feature::enabled()) {
|
||||
$this->error('V5 is only available in development environments.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$hostId = (string) $this->argument('host_id');
|
||||
$ttl = max(60, (int) $this->option('ttl'));
|
||||
$caps = collect(explode(',', (string) $this->option('caps')))
|
||||
->map(fn (string $cap) => trim($cap))
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($caps === []) {
|
||||
$caps = $this->defaultCapabilities();
|
||||
}
|
||||
|
||||
try {
|
||||
$token = $agentTokenIssuer->issue($hostId, $caps, $ttl);
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$output = $this->option('output');
|
||||
|
||||
if (is_string($output) && $output !== '') {
|
||||
$outputPath = Str::startsWith($output, '/') ? $output : base_path($output);
|
||||
File::ensureDirectoryExists(dirname($outputPath));
|
||||
File::put($outputPath, $token.PHP_EOL);
|
||||
chmod($outputPath, 0600);
|
||||
$this->info("Host JWT written to {$outputPath}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->line($token);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Support\V5\V5Feature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* Generates the ES256 (EC P-256) keypair used to authorize coold host agents
|
||||
* against flux. Laravel signs the per-host JWT with the private key
|
||||
* (config('flux.jwt_private_key_path')); flux verifies it with the matching
|
||||
* public key (config('flux.jwt_public_key_path')). Without this keypair a fresh
|
||||
* install cannot mint host tokens, so this command is a bootstrap prerequisite.
|
||||
*
|
||||
* @see AgentTokenIssuer
|
||||
*/
|
||||
class V5FluxGenerateKeys extends Command
|
||||
{
|
||||
protected $signature = 'v5:flux-generate-keys
|
||||
{--force : Overwrite an existing private key instead of refusing}
|
||||
{--show-public : Print the generated public key PEM so it can be provisioned to flux}';
|
||||
|
||||
protected $description = 'Generate the ES256 keypair Flux uses to sign and verify coold host agent JWTs.';
|
||||
|
||||
public function handle(AgentTokenIssuer $agentTokenIssuer): int
|
||||
{
|
||||
if (! V5Feature::enabled()) {
|
||||
$this->error('V5 is only available in development environments.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$privateKeyPath = (string) config('flux.jwt_private_key_path');
|
||||
$publicKeyPath = (string) config('flux.jwt_public_key_path');
|
||||
|
||||
if ($privateKeyPath === '' || $publicKeyPath === '') {
|
||||
$this->error('Flux JWT key paths are not configured (flux.jwt_private_key_path / flux.jwt_public_key_path).');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Idempotent by default: re-running during provisioning must not clobber
|
||||
// a live key (which would instantly invalidate every host token on
|
||||
// disk). Refuse unless --force is passed, and exit SUCCESS so a
|
||||
// provisioning script can call this unconditionally on every deploy.
|
||||
if (File::exists($privateKeyPath) && ! $this->option('force')) {
|
||||
$this->warn("A Flux JWT private key already exists at {$privateKeyPath}.");
|
||||
$this->line('Refusing to overwrite it. Re-run with --force to replace it (this invalidates every host token currently on disk).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// curve_name drives the actual EC key (P-256). private_key_bits is
|
||||
// still validated by PHP's generic length check (>= 384) even though it
|
||||
// is irrelevant to EC, so it must be present or openssl_pkey_new fails
|
||||
// with "Private key length must be at least 384 bits, configured to 0".
|
||||
$keyPair = openssl_pkey_new([
|
||||
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
||||
'curve_name' => 'prime256v1',
|
||||
'private_key_bits' => 384,
|
||||
]);
|
||||
|
||||
if ($keyPair === false) {
|
||||
$this->error('Failed to generate an EC P-256 keypair: '.openssl_error_string());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$privatePem = '';
|
||||
|
||||
if (! openssl_pkey_export($keyPair, $privatePem)) {
|
||||
$this->error('Failed to export the private key PEM: '.openssl_error_string());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$details = openssl_pkey_get_details($keyPair);
|
||||
|
||||
if ($details === false || ! isset($details['key'])) {
|
||||
$this->error('Failed to read the generated public key PEM.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$publicPem = (string) $details['key'];
|
||||
|
||||
$this->writeKeyFile($privateKeyPath, $privatePem, 0600);
|
||||
$this->writeKeyFile($publicKeyPath, $publicPem, 0644);
|
||||
|
||||
// Self-check: the whole point of this command is that AgentTokenIssuer
|
||||
// can mint with the key we just wrote. If the format were wrong (e.g.
|
||||
// not a PEM EC private key Firebase\JWT accepts for ES256) this fails
|
||||
// loudly here instead of silently at the first real host bootstrap.
|
||||
try {
|
||||
$token = $agentTokenIssuer->issue('flux-keygen-selfcheck');
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error('Generated a keypair but AgentTokenIssuer could not mint a token with it: '.$exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (substr_count($token, '.') !== 2) {
|
||||
$this->error('Generated key produced a malformed JWT (expected 3 segments).');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Generated a fresh ES256 (EC P-256) Flux keypair.');
|
||||
$this->line(" Private key (0600): {$privateKeyPath}");
|
||||
$this->line(" Public key (0644): {$publicKeyPath}");
|
||||
$this->newLine();
|
||||
$this->line('Provision the PUBLIC key to flux — flux verifies every host JWT with it.');
|
||||
$this->line('Keep the PRIVATE key secret and on the Laravel host only.');
|
||||
|
||||
if ($this->option('show-public')) {
|
||||
$this->newLine();
|
||||
$this->line(rtrim($publicPem));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a key file with exact permissions, creating the parent directory at
|
||||
* 0700 if missing. chmod is applied after the write because umask can
|
||||
* loosen both the mkdir mode and the created file mode.
|
||||
*/
|
||||
private function writeKeyFile(string $path, string $contents, int $mode): void
|
||||
{
|
||||
$directory = dirname($path);
|
||||
|
||||
if (! is_dir($directory)) {
|
||||
File::makeDirectory($directory, 0700, true);
|
||||
@chmod($directory, 0700);
|
||||
}
|
||||
|
||||
File::put($path, $contents);
|
||||
@chmod($path, $mode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\V5\Server\SyncDevLimaServers;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Support\V5\V5Feature;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class V5SyncDevLimaServers extends Command
|
||||
{
|
||||
protected $signature = 'v5:sync-dev-lima-servers
|
||||
{--team-id=0 : Team that owns the dev servers}
|
||||
{--user-id=0 : User recorded as creator}
|
||||
{--private-key-id= : Optional private key used by the dev servers}
|
||||
{--cluster=Development-Lima : Cluster name for the dev Lima servers}
|
||||
{--server=* : Server as name|host|ssh_user|ssh_port|wireguard_management_ip}';
|
||||
|
||||
protected $description = 'Sync development Lima VMs into the v5 server/cluster tables.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! V5Feature::enabled()) {
|
||||
$this->error('V5 is only available in development environments.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$team = Team::query()->find((int) $this->option('team-id')) ?? Team::query()->orderBy('id')->first();
|
||||
$user = User::query()->find((int) $this->option('user-id')) ?? User::query()->orderBy('id')->first();
|
||||
$privateKeyId = $this->option('private-key-id');
|
||||
$privateKey = is_numeric($privateKeyId)
|
||||
? PrivateKey::query()->find((int) $privateKeyId)
|
||||
: PrivateKey::query()
|
||||
->where('team_id', $team?->id)
|
||||
->where('is_git_related', false)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if (! $team instanceof Team || ! $user instanceof User) {
|
||||
$this->warn('Cannot sync dev Lima servers without an existing team and user.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$servers = $this->option('server');
|
||||
|
||||
if (! is_array($servers) || $servers === []) {
|
||||
$this->warn('No dev Lima servers were provided.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$parsedServers = [];
|
||||
|
||||
foreach ($servers as $server) {
|
||||
$parts = explode('|', (string) $server);
|
||||
|
||||
if (! in_array(count($parts), [4, 5], true)) {
|
||||
$this->error("Invalid server '{$server}'. Expected name|host|ssh_user|ssh_port|wireguard_management_ip.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
[$name, $host, $sshUser, $sshPort] = array_slice($parts, 0, 4);
|
||||
$wireguardManagementIp = ($parts[4] ?? null) ?: null;
|
||||
|
||||
$parsedServers[] = [
|
||||
'name' => $name,
|
||||
'host' => $host,
|
||||
'ssh_user' => $sshUser,
|
||||
'ssh_port' => (int) $sshPort,
|
||||
'wireguard_management_ip' => $wireguardManagementIp,
|
||||
];
|
||||
}
|
||||
|
||||
SyncDevLimaServers::run(
|
||||
team: $team,
|
||||
user: $user,
|
||||
privateKey: $privateKey,
|
||||
clusterName: (string) $this->option('cluster'),
|
||||
servers: $parsedServers,
|
||||
);
|
||||
|
||||
foreach ($parsedServers as $server) {
|
||||
$this->info("Synced {$server['name']} ({$server['host']}:{$server['ssh_port']}).");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums\V5;
|
||||
|
||||
/**
|
||||
* Lifecycle states persisted on `v5_applications.status`.
|
||||
*
|
||||
* Besides Coolify's own states (creating, failed, unknown), the column also
|
||||
* receives raw container runtime states reported by coold, so the Docker and
|
||||
* Podman container states are part of the catalog.
|
||||
*/
|
||||
enum ApplicationStatus: string
|
||||
{
|
||||
case Creating = 'creating';
|
||||
case Configured = 'configured';
|
||||
case Created = 'created';
|
||||
case Starting = 'starting';
|
||||
case Running = 'running';
|
||||
case Restarting = 'restarting';
|
||||
case Paused = 'paused';
|
||||
case Removing = 'removing';
|
||||
case Stopping = 'stopping';
|
||||
case Stopped = 'stopped';
|
||||
case Exited = 'exited';
|
||||
case Dead = 'dead';
|
||||
case Failed = 'failed';
|
||||
case Unknown = 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums\V5;
|
||||
|
||||
/**
|
||||
* Runtime states persisted on `v5_container_statuses.status`.
|
||||
*
|
||||
* Named ContainerState (not ContainerStatus) to avoid clashing with the
|
||||
* App\Models\V5\ContainerStatus Eloquent model. Covers the Docker and Podman
|
||||
* container states reported by coold.
|
||||
*/
|
||||
enum ContainerState: string
|
||||
{
|
||||
case Configured = 'configured';
|
||||
case Created = 'created';
|
||||
case Starting = 'starting';
|
||||
case Running = 'running';
|
||||
case Restarting = 'restarting';
|
||||
case Paused = 'paused';
|
||||
case Removing = 'removing';
|
||||
case Stopping = 'stopping';
|
||||
case Stopped = 'stopped';
|
||||
case Exited = 'exited';
|
||||
case Dead = 'dead';
|
||||
case Unknown = 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums\V5;
|
||||
|
||||
/**
|
||||
* States persisted on `v5_servers.ingress_status`.
|
||||
*
|
||||
* The value mirrors the ingress proxy container's runtime state as reported
|
||||
* by coold, so the Docker and Podman container states are part of the catalog.
|
||||
*/
|
||||
enum IngressStatus: string
|
||||
{
|
||||
case Configured = 'configured';
|
||||
case Created = 'created';
|
||||
case Starting = 'starting';
|
||||
case Running = 'running';
|
||||
case Restarting = 'restarting';
|
||||
case Paused = 'paused';
|
||||
case Removing = 'removing';
|
||||
case Stopping = 'stopping';
|
||||
case Stopped = 'stopped';
|
||||
case Exited = 'exited';
|
||||
case Dead = 'dead';
|
||||
case Unknown = 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums\V5;
|
||||
|
||||
/**
|
||||
* Lifecycle states persisted on `v5_servers.status`.
|
||||
*/
|
||||
enum ServerStatus: string
|
||||
{
|
||||
case Added = 'added';
|
||||
case Installed = 'installed';
|
||||
case Failed = 'failed';
|
||||
case Unreachable = 'unreachable';
|
||||
case Unknown = 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Support\V5\CanvasResourceSerializer;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class V5CanvasResourceUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Push the queued broadcast job only after the dispatching database
|
||||
* transaction commits, so workers never serialize pre-commit state.
|
||||
*/
|
||||
public bool $afterCommit = true;
|
||||
|
||||
public function __construct(
|
||||
public int $teamId,
|
||||
public ?int $applicationId = null,
|
||||
public ?int $caddyIngressServerId = null,
|
||||
public ?int $serverId = null,
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel("team.{$this->teamId}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'v5.canvas.resource.updated';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{application: array<string, mixed>|null, applications: array<int, array<string, mixed>>, caddyIngress: array<string, mixed>|null}
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$serializer = app(CanvasResourceSerializer::class);
|
||||
$application = $this->applicationId !== null
|
||||
? V5Application::query()->with(['server', 'domains'])->find($this->applicationId)
|
||||
: null;
|
||||
$applications = $this->serverId !== null
|
||||
? V5Application::query()
|
||||
->where('server_id', $this->serverId)
|
||||
->with(['server', 'domains'])
|
||||
->get()
|
||||
: collect();
|
||||
$caddyIngress = $this->caddyIngressServerId !== null
|
||||
? V5Server::query()->find($this->caddyIngressServerId)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'application' => $application instanceof V5Application ? $serializer->serializeApplication($application) : null,
|
||||
'applications' => $applications
|
||||
->map(fn (V5Application $application) => $serializer->serializeApplication($application))
|
||||
->values()
|
||||
->all(),
|
||||
'caddyIngress' => $caddyIngress instanceof V5Server && $caddyIngress->isIngress()
|
||||
? $serializer->serializeCaddyIngress($caddyIngress)
|
||||
: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\V5\Cluster as V5Cluster;
|
||||
use App\Support\V5\ClusterSerializer;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class V5ClusterUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Push the queued broadcast job only after the dispatching database
|
||||
* transaction commits, so workers never serialize pre-commit state.
|
||||
*/
|
||||
public bool $afterCommit = true;
|
||||
|
||||
public function __construct(public int $teamId, public int $clusterId) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel("team.{$this->teamId}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'v5.cluster.updated';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{cluster: array<string, mixed>|null}
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$cluster = V5Cluster::query()
|
||||
->where('team_id', $this->teamId)
|
||||
->with(['servers' => fn ($query) => $query
|
||||
->with('privateKey')
|
||||
->orderBy('name')])
|
||||
->withCount('servers')
|
||||
->find($this->clusterId);
|
||||
|
||||
return [
|
||||
'cluster' => $cluster instanceof V5Cluster ? app(ClusterSerializer::class)->serialize($cluster) : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class V5RealtimeTestEvent implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public string $sentAt;
|
||||
|
||||
public function __construct(public int $teamId, public string $message)
|
||||
{
|
||||
$this->sentAt = now()->toJSON();
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel("team.{$this->teamId}"),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'v5.realtime.test';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{message: string, teamId: int, sentAt: string}
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'message' => $this->message,
|
||||
'teamId' => $this->teamId,
|
||||
'sentAt' => $this->sentAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions\V5;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* The per-node coold agent does not implement the dispatched verb. Flux
|
||||
* rejects these before they reach the node, so callers can degrade
|
||||
* gracefully instead of treating the miss as an operational failure.
|
||||
*/
|
||||
class UnsupportedCooldVerb extends RuntimeException
|
||||
{
|
||||
public function __construct(public readonly string $verb, string $message = '')
|
||||
{
|
||||
parent::__construct($message !== '' ? $message : "The node's coold agent does not support the {$verb} verb.");
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Internal;
|
||||
|
||||
use App\Actions\V5\Flux\ApplyFluxResourceStatusUpdate;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class FluxResourceStatusController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
if (! $this->authorizedBearer($request)) {
|
||||
abort(401);
|
||||
}
|
||||
|
||||
$validated = Validator::make($request->all(), [
|
||||
'resource_type' => ['required', 'string', 'max:64'],
|
||||
'team_id' => ['prohibited'],
|
||||
'application_id' => ['prohibited'],
|
||||
'resource_id' => ['prohibited'],
|
||||
'server_id' => ['prohibited'],
|
||||
'host_server_id' => ['prohibited'],
|
||||
'application_uuid' => ['nullable', 'string', 'max:255'],
|
||||
'resource_uuid' => ['nullable', 'string', 'max:255'],
|
||||
'server_uuid' => ['nullable', 'string', 'max:255'],
|
||||
'host_server_uuid' => ['nullable', 'string', 'max:255'],
|
||||
'host_id' => ['nullable', 'string', 'max:255'],
|
||||
'node_id' => ['nullable', 'string', 'max:255'],
|
||||
'server_host' => ['nullable', 'string', 'max:255'],
|
||||
'container_id' => ['nullable', 'string', 'max:255'],
|
||||
'runtime_container_id' => ['nullable', 'string', 'max:255'],
|
||||
'container_name' => ['nullable', 'string', 'max:255'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['required_without:state', 'string', 'max:64'],
|
||||
'state' => ['required_without:status', 'string', 'max:64'],
|
||||
'status_message' => ['nullable', 'string', 'max:1000'],
|
||||
'message' => ['nullable', 'string', 'max:1000'],
|
||||
'observed_at' => ['nullable', 'string', 'date'],
|
||||
])->validate();
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run($validated);
|
||||
|
||||
if ($resource === null) {
|
||||
if (($validated['resource_type'] ?? null) === 'container') {
|
||||
return response()->json([
|
||||
'message' => 'Container status accepted.',
|
||||
], 202);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'No matching v5 resource was found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Resource status updated.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time match the presented bearer token against every accepted
|
||||
* inbound token. Accepting an array (config('flux.laravel_api_tokens'),
|
||||
* falling back to the single config('flux.laravel_api_token')) lets an
|
||||
* operator rotate by serving old+new tokens simultaneously.
|
||||
*
|
||||
* SECURITY: this is still a shared global secret — every flux instance
|
||||
* presents the same token, so it cannot be scoped or revoked per-flux, and
|
||||
* a leak forces a fleet-wide rotation. The target design is per-flux,
|
||||
* individually rotatable tokens; until then the array support above is the
|
||||
* mitigation that makes rotation possible without downtime.
|
||||
*/
|
||||
private function authorizedBearer(Request $request): bool
|
||||
{
|
||||
$presented = (string) $request->bearerToken();
|
||||
|
||||
if ($presented === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->acceptedTokens() as $token) {
|
||||
if (hash_equals($token, $presented)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function acceptedTokens(): array
|
||||
{
|
||||
$tokens = config('flux.laravel_api_tokens', []);
|
||||
$tokens = is_array($tokens) ? $tokens : [];
|
||||
|
||||
$single = config('flux.laravel_api_token');
|
||||
|
||||
if (is_string($single) && $single !== '') {
|
||||
$tokens[] = $single;
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map(fn ($token): string => is_string($token) ? $token : '', $tokens),
|
||||
fn (string $token): bool => $token !== ''
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5;
|
||||
|
||||
use App\Actions\V5\Application\DestroyNginxApplication;
|
||||
use App\Actions\V5\Proxy\StartCaddyIngress;
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Enums\V5\IngressStatus;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Exceptions\V5\UnsupportedCooldVerb;
|
||||
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\ResolvesProjectSelection;
|
||||
use App\Http\Controllers\V5\Concerns\SerializesCanvasResources;
|
||||
use App\Jobs\V5DeployApplicationJob;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ApplicationDomain as V5ApplicationDomain;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use App\Support\V5\CanvasResourceSerializer;
|
||||
use App\Support\V5\ConnectionFirewallSync;
|
||||
use App\Support\V5\StatusObservation;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ApplicationController extends Controller
|
||||
{
|
||||
use HandlesIngressSyncErrors;
|
||||
use ResolvesCurrentTeam;
|
||||
use ResolvesProjectSelection;
|
||||
use SerializesCanvasResources;
|
||||
|
||||
private const DEFAULT_NGINX_IMAGE = 'docker.io/library/nginx:alpine';
|
||||
|
||||
public function __construct(private readonly ConnectionFirewallSync $firewallSync) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->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<int, ResourceConnection> $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<int, ResourceConnection>
|
||||
*/
|
||||
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<int, V5Application> $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<int, string> $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<string, mixed> $attributes
|
||||
* @param array<int, string> $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,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesCurrentTeam;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesProjectSelection;
|
||||
use App\Http\Controllers\V5\Concerns\ValidatesBuilderConfiguration;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Cluster as V5Cluster;
|
||||
use App\Services\Flux\FluxHealth;
|
||||
use App\Support\V5\ClusterSerializer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ClusterController extends Controller
|
||||
{
|
||||
use ResolvesCurrentTeam;
|
||||
use ResolvesProjectSelection;
|
||||
use ValidatesBuilderConfiguration;
|
||||
|
||||
public function index(Request $request, FluxHealth $fluxHealth): Response
|
||||
{
|
||||
$currentTeam = $request->attributes->get('v5.currentTeam');
|
||||
$projects = $this->projects($currentTeam);
|
||||
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
|
||||
|
||||
return Inertia::render('Clusters', [
|
||||
'currentTeam' => $this->serializeCurrentTeam($currentTeam),
|
||||
'flux' => $fluxHealth->check(),
|
||||
'clusters' => $this->clusters($currentTeam),
|
||||
'privateKeys' => $this->privateKeys($currentTeam),
|
||||
'projects' => $projects,
|
||||
'selectedProjectUuid' => $selectedProject['uuid'] ?? null,
|
||||
'selectedEnvironmentUuid' => $selectedEnvironment['uuid'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, V5Cluster $cluster): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('view', [$cluster, $currentTeam]);
|
||||
|
||||
return response()->json([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('create', [V5Cluster::class, $currentTeam]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('v5_clusters', 'name')->where('team_id', $currentTeam->id),
|
||||
],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'wireguard_interface' => ['sometimes', 'string', 'max:32', 'regex:/^[a-zA-Z0-9_.-]+$/'],
|
||||
'wireguard_management_pool' => ['sometimes', 'string', 'max:64', $this->ipv4CidrRule()],
|
||||
'wireguard_listen_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
|
||||
'container_network_pool' => ['sometimes', 'string', 'max:64', $this->ipv4CidrRule()],
|
||||
'container_network_prefix' => ['sometimes', 'integer', 'min:1', 'max:32'],
|
||||
'namespaces' => ['sometimes', 'array', 'min:1'],
|
||||
'namespaces.*' => ['string', 'distinct', 'regex:/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/'],
|
||||
'default_deny_containers' => ['sometimes', 'boolean'],
|
||||
'coold_version' => ['sometimes', 'string', 'max:64'],
|
||||
'corrosion_version' => ['sometimes', 'string', 'max:64'],
|
||||
'corrosion_gossip_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
|
||||
'corrosion_api_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
|
||||
'builder_enabled' => ['sometimes', 'boolean'],
|
||||
'builder_capacity' => $this->builderCapacityRules(
|
||||
$this->requestedBuilderEnabled($request, true)
|
||||
),
|
||||
'builder_cpu_quota' => ['sometimes', 'string', 'max:32'],
|
||||
'builder_memory_max' => ['sometimes', 'string', 'max:32'],
|
||||
'builder_timeout_secs' => ['sometimes', 'integer', 'min:1', 'max:86400'],
|
||||
]);
|
||||
|
||||
$cluster = V5Cluster::query()->create([
|
||||
...$this->defaultClusterConfiguration(),
|
||||
...collect($validated)->except(['name', 'description'])->all(),
|
||||
'team_id' => $currentTeam->id,
|
||||
'created_by_user_id' => $request->user()->id,
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, V5Cluster $cluster): \Illuminate\Http\Response|JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('delete', [$cluster, $currentTeam]);
|
||||
|
||||
if ($cluster->servers()->exists()) {
|
||||
return response()->json([
|
||||
'message' => 'Only empty clusters can be deleted.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$cluster->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function clusters(mixed $currentTeam): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$serializer = app(ClusterSerializer::class);
|
||||
|
||||
return V5Cluster::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->with(['servers' => fn ($query) => $query
|
||||
->with('privateKey')
|
||||
->orderBy('name')])
|
||||
->withCount('servers')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (V5Cluster $cluster) => $serializer->serialize($cluster))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: string, name: string}>
|
||||
*/
|
||||
private function privateKeys(mixed $currentTeam): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return PrivateKey::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->where('is_git_related', false)
|
||||
->orderBy('name')
|
||||
->get(['id', 'uuid', 'name'])
|
||||
->map(fn (PrivateKey $privateKey) => [
|
||||
'id' => $privateKey->uuid,
|
||||
'name' => $privateKey->name,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function defaultClusterConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'wireguard_interface' => V5Cluster::DEFAULT_WIREGUARD_INTERFACE,
|
||||
'wireguard_management_pool' => V5Cluster::DEFAULT_WIREGUARD_MANAGEMENT_POOL,
|
||||
'wireguard_listen_port' => V5Cluster::DEFAULT_WIREGUARD_LISTEN_PORT,
|
||||
'container_network_pool' => V5Cluster::DEFAULT_CONTAINER_NETWORK_POOL,
|
||||
'container_network_prefix' => V5Cluster::DEFAULT_CONTAINER_NETWORK_PREFIX,
|
||||
'namespaces' => V5Cluster::DEFAULT_NAMESPACES,
|
||||
'default_deny_containers' => true,
|
||||
'coold_version' => V5Cluster::DEFAULT_COOLD_VERSION,
|
||||
'corrosion_version' => V5Cluster::DEFAULT_CORROSION_VERSION,
|
||||
'corrosion_gossip_port' => V5Cluster::DEFAULT_CORROSION_GOSSIP_PORT,
|
||||
'corrosion_api_port' => V5Cluster::DEFAULT_CORROSION_API_PORT,
|
||||
'builder_enabled' => true,
|
||||
'builder_capacity' => V5Cluster::DEFAULT_BUILDER_CAPACITY,
|
||||
'builder_cpu_quota' => V5Cluster::DEFAULT_BUILDER_CPU_QUOTA,
|
||||
'builder_memory_max' => V5Cluster::DEFAULT_BUILDER_MEMORY_MAX,
|
||||
'builder_timeout_secs' => V5Cluster::DEFAULT_BUILDER_TIMEOUT_SECS,
|
||||
];
|
||||
}
|
||||
|
||||
private function ipv4CidrRule(): \Closure
|
||||
{
|
||||
return function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if (! is_string($value) || ! str_contains($value, '/')) {
|
||||
$fail('The :attribute must be a valid IPv4 CIDR range.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
[$ip, $prefix] = explode('/', $value, 2);
|
||||
|
||||
if (
|
||||
filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false
|
||||
|| ! ctype_digit($prefix)
|
||||
|| (int) $prefix < 0
|
||||
|| (int) $prefix > 32
|
||||
) {
|
||||
$fail('The :attribute must be a valid IPv4 CIDR range.');
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5\Concerns;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
trait HandlesIngressSyncErrors
|
||||
{
|
||||
protected function ingressSyncErrorResponse(\RuntimeException $exception): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => $this->friendlyIngressSyncError($exception->getMessage()),
|
||||
'detail' => $exception->getMessage(),
|
||||
], 502);
|
||||
}
|
||||
|
||||
protected function friendlyIngressSyncError(string $message): string
|
||||
{
|
||||
$normalized = Str::lower($message);
|
||||
|
||||
if (str_contains($normalized, 'invalid http response') || str_contains($normalized, 'could not talk to flux')) {
|
||||
return 'Could not reach Flux. Check that Flux is running in the Coolify container and try again.';
|
||||
}
|
||||
|
||||
if (str_contains($normalized, 'dispatch timeout') || str_contains($normalized, 'timed out')) {
|
||||
return 'coold did not respond in time. Check that the server agent is running and connected to Flux.';
|
||||
}
|
||||
|
||||
if (str_contains($normalized, 'validate caddyfile')) {
|
||||
return 'Caddy rejected the generated ingress configuration. Check the domains and internal port, then try again.';
|
||||
}
|
||||
|
||||
if (str_contains($normalized, 'start caddy ingress') || str_contains($normalized, 'reload caddy ingress')) {
|
||||
return 'Could not start Caddy ingress on the server. Check that Podman is running and port 80 is available.';
|
||||
}
|
||||
|
||||
return 'Could not update ingress. Check Flux and coold logs, then try again.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5\Concerns;
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ResolvesCurrentTeam
|
||||
{
|
||||
/**
|
||||
* Resolve the current team set by the EnsureCurrentTeam middleware, or
|
||||
* abort with a 404 so resources outside the team stay invisible.
|
||||
*/
|
||||
protected function currentTeamOrFail(Request $request): Team
|
||||
{
|
||||
$currentTeam = $request->attributes->get('v5.currentTeam');
|
||||
|
||||
abort_unless($currentTeam instanceof Team, 404);
|
||||
|
||||
return $currentTeam;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5\Concerns;
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ResolvesProjectSelection
|
||||
{
|
||||
protected const SELECTED_PROJECT_SESSION_KEY = 'v5.selectedProjectUuid';
|
||||
|
||||
protected const SELECTED_ENVIRONMENT_SESSION_KEY = 'v5.selectedEnvironmentUuid';
|
||||
|
||||
/**
|
||||
* @return array{id: int}|null
|
||||
*/
|
||||
protected function serializeCurrentTeam(mixed $currentTeam): ?array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $currentTeam->id,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{uuid: string, name: string, environments: array<int, array{uuid: string, name: string}>}> $projects
|
||||
* @return array{0: array{uuid: string, name: string, environments: array<int, array{uuid: string, name: string}>}|null, 1: array{uuid: string, name: string}|null}
|
||||
*/
|
||||
protected function selectedProjectAndEnvironment(Request $request, array $projects): array
|
||||
{
|
||||
$selectedProjectUuid = $request->query('project', $request->session()->get(self::SELECTED_PROJECT_SESSION_KEY));
|
||||
$selectedEnvironmentUuid = $request->query('environment', $request->session()->get(self::SELECTED_ENVIRONMENT_SESSION_KEY));
|
||||
$selectedProject = null;
|
||||
|
||||
foreach ($projects as $project) {
|
||||
if ($project['uuid'] === $selectedProjectUuid) {
|
||||
$selectedProject = $project;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$selectedProject ??= $projects[0] ?? null;
|
||||
$selectedEnvironment = null;
|
||||
|
||||
foreach ($selectedProject['environments'] ?? [] as $environment) {
|
||||
if ($environment['uuid'] === $selectedEnvironmentUuid) {
|
||||
$selectedEnvironment = $environment;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$selectedEnvironment ??= $selectedProject['environments'][0] ?? null;
|
||||
|
||||
if ($request->query->has('project') || $request->query->has('environment')) {
|
||||
$request->session()->put([
|
||||
self::SELECTED_PROJECT_SESSION_KEY => $selectedProject['uuid'] ?? null,
|
||||
self::SELECTED_ENVIRONMENT_SESSION_KEY => $selectedEnvironment['uuid'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return [$selectedProject, $selectedEnvironment];
|
||||
}
|
||||
|
||||
protected function selectedEnvironment(Project $project, ?string $environmentUuid): ?Environment
|
||||
{
|
||||
if ($environmentUuid === null) {
|
||||
return $project->environments->first();
|
||||
}
|
||||
|
||||
$environment = $project->environments->firstWhere('uuid', $environmentUuid);
|
||||
|
||||
if (! $environment instanceof Environment) {
|
||||
abort(422, 'The selected environment is not available for the selected project.');
|
||||
}
|
||||
|
||||
return $environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{uuid: string, name: string, environments: array<int, array{uuid: string, name: string}>}>
|
||||
*/
|
||||
protected function projects(mixed $currentTeam): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->projectQuery($currentTeam)
|
||||
->get()
|
||||
->map(fn (Project $project) => [
|
||||
'uuid' => $project->uuid,
|
||||
'name' => $project->name,
|
||||
'environments' => $project->environments
|
||||
->map(fn ($environment) => [
|
||||
'uuid' => $environment->uuid,
|
||||
'name' => $environment->name,
|
||||
])
|
||||
->all(),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function projectQuery(Team $currentTeam): Builder
|
||||
{
|
||||
return Project::query()
|
||||
->select(['id', 'uuid', 'name', 'team_id'])
|
||||
->where('team_id', $currentTeam->id)
|
||||
->with(['environments' => fn ($query) => $query
|
||||
->select(['id', 'uuid', 'name', 'project_id'])
|
||||
->orderByRaw("CASE WHEN LOWER(name) = 'production' THEN 0 ELSE 1 END")
|
||||
->orderByRaw('LOWER(name)')])
|
||||
->orderByRaw('LOWER(name)');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5\Concerns;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Support\V5\CanvasResourceSerializer;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
trait SerializesCanvasResources
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function serializeApplication(V5Application $application): array
|
||||
{
|
||||
return app(CanvasResourceSerializer::class)->serializeApplication($application);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function serializeCaddyIngress(V5Server $server, int $index = 0): array
|
||||
{
|
||||
return app(CanvasResourceSerializer::class)->serializeCaddyIngress($server, $index);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
protected function caddyIngresses(mixed $currentTeam): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return V5Server::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->filter(fn (V5Server $server) => $server->isIngress())
|
||||
->values()
|
||||
->map(fn (V5Server $server, int $index) => $this->serializeCaddyIngress($server, $index))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{uuid: string} $selectedProject
|
||||
* @param array{uuid: string} $selectedEnvironment
|
||||
* @return Builder<V5Application>
|
||||
*/
|
||||
protected function applicationQuery(Team $currentTeam, array $selectedProject, array $selectedEnvironment): Builder
|
||||
{
|
||||
return V5Application::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->whereHas('project', fn (Builder $query) => $query
|
||||
->where('team_id', $currentTeam->id)
|
||||
->where('uuid', $selectedProject['uuid']))
|
||||
->whereHas('environment', fn (Builder $query) => $query
|
||||
->where('uuid', $selectedEnvironment['uuid']));
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5\Concerns;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ValidatesBuilderConfiguration
|
||||
{
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function builderCapacityRules(bool $builderEnabled, bool $required = false): array
|
||||
{
|
||||
return [
|
||||
$required ? 'required' : 'sometimes',
|
||||
'integer',
|
||||
$builderEnabled ? 'min:1' : 'min:0',
|
||||
'max:1000',
|
||||
];
|
||||
}
|
||||
|
||||
protected function requestedBuilderEnabled(Request $request, bool $default): bool
|
||||
{
|
||||
if (! $request->has('builder_enabled')) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $request->boolean('builder_enabled');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5;
|
||||
|
||||
use App\Events\V5RealtimeTestEvent;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesCurrentTeam;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesProjectSelection;
|
||||
use App\Http\Controllers\V5\Concerns\SerializesCanvasResources;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\FluxHealth;
|
||||
use App\Support\V5\ResourceConnectionSerializer;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
use ResolvesCurrentTeam;
|
||||
use ResolvesProjectSelection;
|
||||
use SerializesCanvasResources;
|
||||
|
||||
public function __construct(private readonly ResourceConnectionSerializer $connectionSerializer) {}
|
||||
|
||||
public function __invoke(Request $request, FluxHealth $fluxHealth): Response
|
||||
{
|
||||
$currentTeam = $request->attributes->get('v5.currentTeam');
|
||||
$projects = $this->projects($currentTeam);
|
||||
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
|
||||
$applications = $this->applications($currentTeam, $selectedProject, $selectedEnvironment);
|
||||
$requestedApplicationUuid = $request->query('application');
|
||||
$selectedApplicationUuid = collect($applications)->contains(
|
||||
fn (array $application): bool => $application['id'] === $requestedApplicationUuid
|
||||
) ? $requestedApplicationUuid : null;
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'currentTeam' => $this->serializeCurrentTeam($currentTeam),
|
||||
'flux' => $fluxHealth->check(),
|
||||
'applications' => $applications,
|
||||
'caddyIngresses' => $this->caddyIngresses($currentTeam),
|
||||
'resourceConnections' => $this->resourceConnections($currentTeam, $selectedProject, $selectedEnvironment),
|
||||
'nginxServers' => $this->nginxServers($currentTeam),
|
||||
'projects' => $projects,
|
||||
'selectedProjectUuid' => $selectedProject['uuid'] ?? null,
|
||||
'selectedEnvironmentUuid' => $selectedEnvironment['uuid'] ?? null,
|
||||
'selectedApplicationUuid' => $selectedApplicationUuid,
|
||||
]);
|
||||
}
|
||||
|
||||
public function realtimeTest(Request $request): Response
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
|
||||
return Inertia::render('RealtimeTest', [
|
||||
'currentTeam' => [
|
||||
'id' => $currentTeam->id,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function broadcastRealtimeTest(Request $request): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'message' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
V5RealtimeTestEvent::dispatch(
|
||||
$currentTeam->id,
|
||||
$validated['message'] ?? 'Manual v5 realtime test'
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Realtime test event broadcasted.',
|
||||
], 202);
|
||||
}
|
||||
|
||||
public function updateSelection(Request $request): \Illuminate\Http\Response
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'project_uuid' => ['required', 'string'],
|
||||
'environment_uuid' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$project = $this->projectQuery($currentTeam)
|
||||
->where('uuid', $validated['project_uuid'])
|
||||
->first();
|
||||
|
||||
if (! $project instanceof Project) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$environment = $this->selectedEnvironment($project, $validated['environment_uuid'] ?? null);
|
||||
|
||||
$request->session()->put([
|
||||
self::SELECTED_PROJECT_SESSION_KEY => $project->uuid,
|
||||
self::SELECTED_ENVIRONMENT_SESSION_KEY => $environment?->uuid,
|
||||
]);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function applications(mixed $currentTeam, ?array $selectedProject, ?array $selectedEnvironment): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team || $selectedProject === null || $selectedEnvironment === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->applicationQuery($currentTeam, $selectedProject, $selectedEnvironment)
|
||||
->with('server')
|
||||
->orderBy('created_at')
|
||||
->get()
|
||||
->map(fn (V5Application $application) => $this->serializeApplication($application))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function resourceConnections(mixed $currentTeam, ?array $selectedProject, ?array $selectedEnvironment): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team || $selectedProject === null || $selectedEnvironment === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ResourceConnection::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->whereHas('project', fn (Builder $query) => $query
|
||||
->where('team_id', $currentTeam->id)
|
||||
->where('uuid', $selectedProject['uuid']))
|
||||
->whereHas('environment', fn (Builder $query) => $query
|
||||
->where('uuid', $selectedEnvironment['uuid']))
|
||||
->with('rules')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(fn (ResourceConnection $connection) => $this->connectionSerializer->serialize($connection))
|
||||
->all();
|
||||
}
|
||||
|
||||
private function nginxServers(mixed $currentTeam): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return V5Server::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->orderByRaw('last_bootstrapped_at is null')
|
||||
->orderBy('name')
|
||||
->get(['id', 'uuid', 'name', 'host', 'status'])
|
||||
->map(fn (V5Server $server) => [
|
||||
'id' => $server->uuid,
|
||||
'name' => $server->name,
|
||||
'host' => $server->host,
|
||||
'status' => $server->status,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesCurrentTeam;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesProjectSelection;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use App\Support\V5\ConnectionFirewallSync;
|
||||
use App\Support\V5\ResourceConnectionSerializer;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ResourceConnectionController extends Controller
|
||||
{
|
||||
use ResolvesCurrentTeam;
|
||||
use ResolvesProjectSelection;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConnectionFirewallSync $firewallSync,
|
||||
private readonly ResourceConnectionSerializer $connectionSerializer,
|
||||
) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('create', [ResourceConnection::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 connecting resources.',
|
||||
], 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([
|
||||
'resource_one' => ['required', 'array'],
|
||||
'resource_one.type' => ['required', 'string', Rule::in(['application'])],
|
||||
'resource_one.uuid' => ['required', 'string', 'max:255'],
|
||||
'resource_two' => ['required', 'array'],
|
||||
'resource_two.type' => ['required', 'string', Rule::in(['application'])],
|
||||
'resource_two.uuid' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$resourceOne = $this->resolveConnectableResource($currentTeam, $project, $environment, $validated['resource_one']);
|
||||
$resourceTwo = $this->resolveConnectableResource($currentTeam, $project, $environment, $validated['resource_two']);
|
||||
|
||||
if ($this->resourceIdentity($resourceOne) === $this->resourceIdentity($resourceTwo)) {
|
||||
return response()->json([
|
||||
'message' => 'A resource cannot connect to itself.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$connection = ResourceConnection::query()->firstOrCreate(
|
||||
[
|
||||
'team_id' => $currentTeam->id,
|
||||
'resource_pair_key' => $this->resourcePairKey($resourceOne, $resourceTwo),
|
||||
],
|
||||
[
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'resource_one_type' => $resourceOne->getMorphClass(),
|
||||
'resource_one_id' => $resourceOne->getKey(),
|
||||
'resource_two_type' => $resourceTwo->getMorphClass(),
|
||||
'resource_two_id' => $resourceTwo->getKey(),
|
||||
'created_by_user_id' => $request->user()->id,
|
||||
],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'connection' => $this->connectionSerializer->serialize($connection->load('rules')),
|
||||
], $connection->wasRecentlyCreated ? 201 : 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the connection's rules, then converge the node firewalls.
|
||||
*
|
||||
* Ordering & failure semantics:
|
||||
* 1. Snapshot the current DB rules and their firewall representation; abort
|
||||
* with 502 before mutating anything when the snapshot cannot be built.
|
||||
* 2. Commit the requested rules in a DB transaction — the DB always holds
|
||||
* the desired state.
|
||||
* 3. Converge the node firewalls through Flux. Nodes whose coold lacks the
|
||||
* firewall verbs (UnsupportedCooldVerb) are tolerated: the committed
|
||||
* rules are kept and the request succeeds.
|
||||
* 4. On a real Flux failure the previous rules are restored in a second DB
|
||||
* transaction, the node firewalls are rolled back to the restored rules
|
||||
* best-effort (warning-logged when that also fails — deterministic rule
|
||||
* ids keep a later re-sync idempotent), and the original error surfaces
|
||||
* to the caller as a 502 {message, detail} response.
|
||||
*/
|
||||
public function update(Request $request, ResourceConnection $connection, FluxClient $fluxClient): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('update', [$connection, $currentTeam]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'ports_by_direction' => ['present', 'array'],
|
||||
'ports_by_direction.*' => ['array'],
|
||||
'ports_by_direction.*.*' => ['integer', 'min:1', 'max:65535', 'distinct'],
|
||||
]);
|
||||
|
||||
$connection->load('rules');
|
||||
$oldRulePayloads = $connection->rules
|
||||
->map(fn ($rule): array => [
|
||||
'source_resource_type' => $rule->source_resource_type,
|
||||
'source_resource_id' => $rule->source_resource_id,
|
||||
'target_resource_type' => $rule->target_resource_type,
|
||||
'target_resource_id' => $rule->target_resource_id,
|
||||
'protocol' => $rule->protocol,
|
||||
'port' => $rule->port,
|
||||
])
|
||||
->all();
|
||||
|
||||
try {
|
||||
$oldFirewallRules = $this->firewallSync->rulesFor($connection);
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
Log::warning('V5 resource connection firewall snapshot failed', [
|
||||
'connection_id' => $connection->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Could not sync firewall rules through Flux.',
|
||||
'detail' => 'The connection was left unchanged. Check the server diagnostics and try again.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($connection, $validated): void {
|
||||
$connection->rules()->delete();
|
||||
$resourcesByUuid = $this->connectionSerializer->applicationsByUuid($connection);
|
||||
|
||||
foreach ($validated['ports_by_direction'] as $direction => $ports) {
|
||||
[$sourceResourceUuid, $targetResourceUuid] = array_pad(explode('->', (string) $direction, 2), 2, null);
|
||||
$sourceResource = is_string($sourceResourceUuid) ? $resourcesByUuid->get($sourceResourceUuid) : null;
|
||||
$targetResource = is_string($targetResourceUuid) ? $resourcesByUuid->get($targetResourceUuid) : null;
|
||||
|
||||
if (! $sourceResource instanceof V5Application || ! $targetResource instanceof V5Application) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (array_unique($ports) as $port) {
|
||||
$connection->rules()->create([
|
||||
'source_resource_type' => $this->resourceTypeForConnectionUuid($connection, $sourceResource->uuid),
|
||||
'source_resource_id' => $sourceResource->id,
|
||||
'target_resource_type' => $this->resourceTypeForConnectionUuid($connection, $targetResource->uuid),
|
||||
'target_resource_id' => $targetResource->id,
|
||||
'protocol' => 'tcp',
|
||||
'port' => (int) $port,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$connection->refresh()->load('rules');
|
||||
|
||||
$newFirewallRules = null;
|
||||
|
||||
try {
|
||||
$newFirewallRules = $this->firewallSync->rulesFor($connection);
|
||||
$this->firewallSync->sync($fluxClient, $oldFirewallRules, $newFirewallRules);
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->restoreConnectionRules($connection, $oldRulePayloads);
|
||||
$this->rollBackFirewallRules($fluxClient, $connection, $newFirewallRules, $oldFirewallRules);
|
||||
report($exception);
|
||||
Log::warning('V5 resource connection firewall sync failed', [
|
||||
'connection_id' => $connection->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Could not sync firewall rules through Flux.',
|
||||
'detail' => 'The previous rules were restored. Check the server diagnostics and try again.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'connection' => $this->connectionSerializer->serialize($connection),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the connection using revoke-first ordering.
|
||||
*
|
||||
* The node firewall rules are revoked before any DB rows are removed. When
|
||||
* a revoke fails with a real error the delete is aborted with a 502
|
||||
* {message, detail} response so the DB never loses track of rules that may
|
||||
* still be open on a reachable node; UnsupportedCooldVerb and
|
||||
* already-missing rules are tolerated. When the firewall snapshot cannot
|
||||
* be built (an endpoint lost its server host id) the node cannot be
|
||||
* addressed at all, so the failure is reported and the delete proceeds.
|
||||
* Deterministic rule ids make a retried delete revoke the same node-side
|
||||
* rules idempotently.
|
||||
*/
|
||||
public function destroy(Request $request, ResourceConnection $connection, FluxClient $fluxClient): Response|JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('delete', [$connection, $currentTeam]);
|
||||
|
||||
try {
|
||||
$oldFirewallRules = $this->firewallSync->rulesFor($connection->load('rules'));
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
$oldFirewallRules = collect();
|
||||
}
|
||||
|
||||
try {
|
||||
$this->firewallSync->sync($fluxClient, $oldFirewallRules, collect());
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
Log::warning('V5 resource connection firewall revoke failed', [
|
||||
'connection_id' => $connection->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Could not sync firewall rules through Flux.',
|
||||
'detail' => 'The connection was not deleted. Check the server diagnostics and try again.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
$connection->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{type: string, uuid: string} $resource
|
||||
*/
|
||||
private function resolveConnectableResource(Team $team, Project $project, Environment $environment, array $resource): Model
|
||||
{
|
||||
return match ($resource['type']) {
|
||||
'application' => V5Application::query()
|
||||
->where('team_id', $team->id)
|
||||
->where('project_id', $project->id)
|
||||
->where('environment_id', $environment->id)
|
||||
->where('uuid', $resource['uuid'])
|
||||
->firstOrFail(),
|
||||
};
|
||||
}
|
||||
|
||||
private function resourcePairKey(Model $resourceOne, Model $resourceTwo): string
|
||||
{
|
||||
return collect([
|
||||
$this->resourceIdentity($resourceOne),
|
||||
$this->resourceIdentity($resourceTwo),
|
||||
])->sort()->implode('|');
|
||||
}
|
||||
|
||||
private function resourceIdentity(Model $resource): string
|
||||
{
|
||||
return $resource->getMorphClass().':'.$resource->getKey();
|
||||
}
|
||||
|
||||
private function resourceTypeForConnectionUuid(ResourceConnection $connection, string $resourceUuid): string
|
||||
{
|
||||
$resourcesByUuid = $this->connectionSerializer->applicationsByUuid($connection);
|
||||
$resource = $resourcesByUuid->get($resourceUuid);
|
||||
|
||||
return $resource instanceof V5Application && (int) $connection->resource_one_id === $resource->id
|
||||
? $connection->resource_one_type
|
||||
: $connection->resource_two_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rulePayloads
|
||||
*/
|
||||
private function restoreConnectionRules(ResourceConnection $connection, array $rulePayloads): void
|
||||
{
|
||||
DB::transaction(function () use ($connection, $rulePayloads): void {
|
||||
$connection->rules()->delete();
|
||||
|
||||
foreach ($rulePayloads as $rulePayload) {
|
||||
$connection->rules()->create($rulePayload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort roll back of a partially converged node firewall to the
|
||||
* restored rules after a failed forward sync. Skipped when the forward
|
||||
* sync never started (the node was not touched). Failures are only logged
|
||||
* because the DB already holds the restored, authoritative rules and the
|
||||
* deterministic rule ids keep a later re-sync idempotent.
|
||||
*
|
||||
* @param Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}>|null $attemptedFirewallRules
|
||||
* @param Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}> $restoredFirewallRules
|
||||
*/
|
||||
private function rollBackFirewallRules(FluxClient $fluxClient, ResourceConnection $connection, ?Collection $attemptedFirewallRules, Collection $restoredFirewallRules): void
|
||||
{
|
||||
if (! $attemptedFirewallRules instanceof Collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->firewallSync->sync($fluxClient, $attemptedFirewallRules, $restoredFirewallRules);
|
||||
} catch (\RuntimeException $exception) {
|
||||
Log::warning('V5 resource connection firewall rollback failed; node firewall may diverge from the restored rules until the next sync', [
|
||||
'connection_id' => $connection->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,901 @@
|
||||
<?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\AgentTokenIssuer;
|
||||
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 restartCoold(Request $request, V5Cluster $cluster, V5Server $server, AgentTokenIssuer $agentTokenIssuer, FluxClient $fluxClient): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('restartCoold', [$server, $currentTeam, $cluster]);
|
||||
|
||||
$server->loadMissing('privateKey');
|
||||
|
||||
if (! $server->privateKey instanceof PrivateKey) {
|
||||
return response()->json([
|
||||
'message' => 'No private key is attached to this server.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$token = $agentTokenIssuer->issueForServer($server);
|
||||
$output = $this->restartCooldOverSsh($server, $token);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('V5 coold restart over SSH failed', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => str($e->getMessage() !== '' ? $e->getMessage() : 'Could not restart coold over SSH.')->limit(10000)->toString(),
|
||||
], 502);
|
||||
}
|
||||
|
||||
$connected = false;
|
||||
|
||||
try {
|
||||
usleep(500_000);
|
||||
$fluxClient->cooldLogs($server->fluxHostId(), 1);
|
||||
$connected = true;
|
||||
$server->forceFill([
|
||||
'status' => ServerStatus::Installed->value,
|
||||
'last_status_check' => 'flux',
|
||||
'last_status_output' => 'coold restarted over SSH and reconnected to Flux.',
|
||||
])->save();
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('V5 coold restart succeeded but Flux reconnect is not confirmed yet', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
'output' => $output,
|
||||
'connected' => $connected,
|
||||
'restartedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function restartCooldOverSsh(V5Server $server, string $token): string
|
||||
{
|
||||
$encodedToken = base64_encode($token);
|
||||
$script = implode(PHP_EOL, [
|
||||
'set -e',
|
||||
"SUDO=''",
|
||||
'if [ "$(id -u)" != "0" ]; then SUDO="sudo -n"; fi',
|
||||
'$SUDO mkdir -p /etc/coolify',
|
||||
'printf %s '.escapeshellarg($encodedToken).' | base64 -d | $SUDO tee /etc/coolify/host-jwt >/dev/null',
|
||||
'$SUDO chmod 600 /etc/coolify/host-jwt',
|
||||
'$SUDO systemctl reset-failed coold.service || true',
|
||||
'$SUDO systemctl restart coold.service',
|
||||
'$SUDO systemctl is-active coold.service',
|
||||
'$SUDO systemctl status coold.service --no-pager -l | sed -n "1,18p"',
|
||||
]);
|
||||
|
||||
return $this->runServerSshCommand($server, $script, 'SSH coold restart command failed.', 45);
|
||||
}
|
||||
|
||||
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, int $timeout = 15): 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($timeout)->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(),
|
||||
]);
|
||||
|
||||
if ($server->privateKey instanceof PrivateKey) {
|
||||
try {
|
||||
return response()->json([
|
||||
'rules' => $this->firewallRulesOverSsh($server, (string) ($validated['namespace'] ?? '')),
|
||||
'source' => 'ssh',
|
||||
'fetchedAt' => now()->toJSON(),
|
||||
]);
|
||||
} catch (\Throwable $sshException) {
|
||||
Log::warning('V5 firewall rules SSH fallback failed', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $sshException->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(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}>
|
||||
*/
|
||||
private function firewallRulesOverSsh(V5Server $server, string $namespace): array
|
||||
{
|
||||
$script = str_replace('__NAMESPACE__', json_encode($namespace, JSON_THROW_ON_ERROR), <<<'PYTHON'
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
namespace = __NAMESPACE__
|
||||
path = Path("/etc/coolify/firewall-rules.tsv")
|
||||
rules = []
|
||||
|
||||
if path.exists():
|
||||
for line in path.read_text().splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) == 6:
|
||||
rule_id, rule_namespace, src, dst, proto, port = parts
|
||||
elif len(parts) == 5:
|
||||
rule_id = ""
|
||||
rule_namespace, src, dst, proto, port = parts
|
||||
else:
|
||||
continue
|
||||
|
||||
if namespace and rule_namespace != namespace:
|
||||
continue
|
||||
|
||||
try:
|
||||
port = int(port)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
rules.append({
|
||||
"id": rule_id,
|
||||
"namespace": rule_namespace,
|
||||
"src": src,
|
||||
"dst": dst,
|
||||
"proto": proto,
|
||||
"port": port,
|
||||
})
|
||||
|
||||
print(json.dumps(rules, separators=(",", ":")))
|
||||
PY
|
||||
PYTHON);
|
||||
|
||||
$output = $this->runServerSshCommand($server, $script, 'SSH firewall rules command failed.');
|
||||
$rules = json_decode($output, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return is_array($rules) ? $rules : [];
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware\V5;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureCurrentTeam
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$currentTeam = $this->resolveCurrentTeam($user);
|
||||
|
||||
if (! $currentTeam) {
|
||||
abort(403, 'No team available for this user.');
|
||||
}
|
||||
|
||||
// The v4 UI stores a full Team model under the same session key and
|
||||
// reads arbitrary columns off it, so only rewrite the session when the
|
||||
// resolved team actually changed — and always store the full model.
|
||||
if (data_get(session('currentTeam'), 'id') !== $currentTeam->id) {
|
||||
session(['currentTeam' => $currentTeam]);
|
||||
}
|
||||
|
||||
$request->attributes->set('v5.currentTeam', $currentTeam);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function resolveCurrentTeam(User $user): ?Team
|
||||
{
|
||||
$sessionTeamId = data_get(session('currentTeam'), 'id');
|
||||
|
||||
if ($sessionTeamId) {
|
||||
$sessionTeam = $user->teams()
|
||||
->whereKey($sessionTeamId)
|
||||
->first();
|
||||
|
||||
if ($sessionTeam) {
|
||||
return $sessionTeam;
|
||||
}
|
||||
}
|
||||
|
||||
return $user->teams()
|
||||
->orderBy('teams.id')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware\V5;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
protected $rootView = 'v5.app';
|
||||
|
||||
public function share(Request $request): array
|
||||
{
|
||||
return [
|
||||
...parent::share($request),
|
||||
'auth' => [
|
||||
'user' => $request->user() ? [
|
||||
'id' => $request->user()->id,
|
||||
'name' => $request->user()->name,
|
||||
'email' => $request->user()->email,
|
||||
] : null,
|
||||
],
|
||||
'currentTeam' => $request->attributes->get('v5.currentTeam') ? [
|
||||
'id' => $request->attributes->get('v5.currentTeam')->id,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,839 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\V5\Proxy\StartCaddyIngress;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\V5\Cluster as V5Cluster;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
class V5BootstrapServerJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
private const BOOTSTRAP_MARKER_PATH = '/etc/coolify/v5-node.json';
|
||||
|
||||
public const TIMEOUT_SECONDS = 7200;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = self::TIMEOUT_SECONDS;
|
||||
|
||||
/**
|
||||
* Second idempotency layer on top of the controller's DB bootstrap claim,
|
||||
* aligned with its running-claim window (TIMEOUT_SECONDS plus margin).
|
||||
*/
|
||||
public int $uniqueFor = self::TIMEOUT_SECONDS + 300;
|
||||
|
||||
public function __construct(public int $clusterId, public int $serverId) {}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return (string) $this->serverId;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$cluster = V5Cluster::query()->findOrFail($this->clusterId);
|
||||
$server = V5Server::query()->with('privateKey')->findOrFail($this->serverId);
|
||||
|
||||
if ($server->cluster_id !== $cluster->id || $server->last_bootstrapped_at !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$installedServers = $cluster->servers()
|
||||
->with('privateKey')
|
||||
->whereNotNull('last_bootstrapped_at')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$action = $installedServers->isEmpty() ? 'bootstrap' : 'extend';
|
||||
$servers = $installedServers->toBase()
|
||||
->push($server)
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
$started = V5Server::query()
|
||||
->whereKey($server->id)
|
||||
->where('last_bootstrap_status', 'queued')
|
||||
->update([
|
||||
'last_bootstrap_action' => $action,
|
||||
'last_bootstrap_status' => 'running',
|
||||
'last_bootstrap_output' => "Starting Coolify CLI {$action} for {$server->name}...",
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
if ($started === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$server->refresh();
|
||||
|
||||
if ($servers->contains(fn (V5Server $server) => ! $server->privateKey instanceof PrivateKey)) {
|
||||
$this->markFailed($server, $action, 'The new server and every already-bootstrapped server in this cluster must have a private key before extending the cluster.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->broadcastClusterUpdated($server);
|
||||
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
if (! is_dir($keyDirectory)) {
|
||||
mkdir($keyDirectory, 0700, true);
|
||||
}
|
||||
|
||||
$tempDirectory = $keyDirectory.'/v5_bootstrap_'.str()->random(16);
|
||||
if (! mkdir($tempDirectory, 0700, true) && ! is_dir($tempDirectory)) {
|
||||
$this->markFailed($server, $action, 'Could not create a temporary SSH configuration directory.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$sshConfigLocation = $this->writeBootstrapSshConfig($servers, $tempDirectory);
|
||||
$existingBootstrap = $this->detectExistingBootstrap($server, $sshConfigLocation);
|
||||
|
||||
if (($existingBootstrap['cluster_id'] ?? null) !== null) {
|
||||
$markerClusterUuid = $existingBootstrap['cluster_uuid'] ?? null;
|
||||
|
||||
if (
|
||||
(string) $existingBootstrap['cluster_id'] !== (string) $cluster->id
|
||||
|| (is_string($markerClusterUuid) && $markerClusterUuid !== $cluster->uuid)
|
||||
) {
|
||||
$this->markFailed($server, $action, 'This server is already bootstrapped for another cluster. Reset the host bootstrap state before joining this cluster.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->adoptExistingBootstrap($cluster, $server, $existingBootstrap, $sshConfigLocation);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = Process::timeout(7200)
|
||||
->run($this->bootstrapCommand($cluster, $servers, $server, $sshConfigLocation, $action));
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
$successful = $result->successful();
|
||||
|
||||
$server->update([
|
||||
'last_bootstrap_action' => $action,
|
||||
'last_bootstrap_status' => $successful ? 'succeeded' : 'failed',
|
||||
'last_bootstrap_output' => str($output !== '' ? $output : 'No output returned.')->limit(20000)->toString(),
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
$this->broadcastClusterUpdated($server);
|
||||
|
||||
if (! $successful) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->persistBootstrapAssignments($cluster, $server, $result->output(), $sshConfigLocation);
|
||||
$server->refresh();
|
||||
|
||||
// Resolve the coold version once so the on-host marker and the
|
||||
// database row always agree.
|
||||
$cooldVersion = $this->bootstrappedCooldVersion($cluster, $result->output());
|
||||
|
||||
$this->writeBootstrapMarker($cluster, $server, $sshConfigLocation, $cooldVersion);
|
||||
|
||||
$this->enrollCooldIntoFlux($server, $sshConfigLocation);
|
||||
$this->waitForFluxHostConnection($server);
|
||||
|
||||
$server->update([
|
||||
'status' => ServerStatus::Installed->value,
|
||||
'has_coold' => true,
|
||||
'coold_version' => $cooldVersion,
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
$this->broadcastClusterUpdated($server);
|
||||
|
||||
if ($server->isIngress()) {
|
||||
StartCaddyIngress::run($server->fresh('privateKey'));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->markFailed($server, $action, $e->getMessage());
|
||||
} finally {
|
||||
$this->deleteDirectory($tempDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
$server = V5Server::query()->find($this->serverId);
|
||||
|
||||
if (! $server instanceof V5Server) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markFailed($server, $server->last_bootstrap_action ?? 'bootstrap', $exception?->getMessage() ?? 'Bootstrap job failed.');
|
||||
|
||||
Log::warning('V5 server bootstrap job failed', [
|
||||
'server_id' => $this->serverId,
|
||||
'cluster_id' => $this->clusterId,
|
||||
'exception' => $exception?->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function markFailed(V5Server $server, string $action, string $output): void
|
||||
{
|
||||
$server->update([
|
||||
'last_bootstrap_action' => $action,
|
||||
'last_bootstrap_status' => 'failed',
|
||||
'last_bootstrap_output' => str($output)->limit(20000)->toString(),
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
$this->broadcastClusterUpdated($server);
|
||||
}
|
||||
|
||||
private function broadcastClusterUpdated(V5Server $server): void
|
||||
{
|
||||
V5ClusterUpdated::dispatch($server->team_id, $server->cluster_id);
|
||||
}
|
||||
|
||||
private function bootstrapCommand(V5Cluster $cluster, Collection $servers, V5Server $newServer, string $sshConfigLocation, string $action): array
|
||||
{
|
||||
$command = [
|
||||
$this->coolifyCliBin(),
|
||||
'init',
|
||||
$action,
|
||||
'--format',
|
||||
'json',
|
||||
'--nodes',
|
||||
$servers->map(fn (V5Server $server) => $this->bootstrapNode($server))->implode(','),
|
||||
'--ssh-config',
|
||||
$sshConfigLocation,
|
||||
'--ssh-user',
|
||||
$newServer->ssh_user,
|
||||
'--namespaces',
|
||||
implode(',', $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES),
|
||||
'--container-pool',
|
||||
$cluster->container_network_pool,
|
||||
'--container-prefix',
|
||||
(string) $cluster->container_network_prefix,
|
||||
'--wg-mgmt-pool',
|
||||
$cluster->wireguard_management_pool,
|
||||
'--wg-interface',
|
||||
$cluster->wireguard_interface,
|
||||
'--wg-listen-port',
|
||||
(string) $cluster->wireguard_listen_port,
|
||||
'--coold-version',
|
||||
$cluster->coold_version,
|
||||
'--corrosion-version',
|
||||
$cluster->corrosion_version,
|
||||
'--corrosion-gossip-port',
|
||||
(string) $cluster->corrosion_gossip_port,
|
||||
'--corrosion-api-port',
|
||||
(string) $cluster->corrosion_api_port,
|
||||
];
|
||||
|
||||
if ($action === 'extend') {
|
||||
array_push($command, '--new-nodes', $this->bootstrapNode($newServer));
|
||||
}
|
||||
|
||||
$listenOverrides = $this->wireguardListenPortOverrides($servers);
|
||||
if ($listenOverrides !== '') {
|
||||
array_push($command, '--wg-listen-port-overrides', $listenOverrides);
|
||||
}
|
||||
|
||||
$endpointOverrides = $this->wireguardEndpointOverrides($servers);
|
||||
if ($endpointOverrides !== '') {
|
||||
array_push($command, '--wg-endpoint-overrides', $endpointOverrides);
|
||||
}
|
||||
|
||||
if (! $cluster->default_deny_containers) {
|
||||
$command[] = '--skip-default-deny';
|
||||
}
|
||||
|
||||
$command[] = '--yes';
|
||||
|
||||
return $command;
|
||||
}
|
||||
|
||||
private function coolifyCliBin(): string
|
||||
{
|
||||
$configuredBinary = (string) config('coold.coolify_cli_bin', '/usr/local/bin/coolify');
|
||||
$devBinary = base_path('.dev/bin/coolify');
|
||||
|
||||
if ($configuredBinary === '/usr/local/bin/coolify' && $this->isRunnableDevelopmentCliBinary($devBinary)) {
|
||||
return $devBinary;
|
||||
}
|
||||
|
||||
return $configuredBinary;
|
||||
}
|
||||
|
||||
private function isRunnableDevelopmentCliBinary(string $binary): bool
|
||||
{
|
||||
if (! is_file($binary)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$header = file_get_contents($binary, false, null, 0, 4);
|
||||
|
||||
if ($header === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_starts_with($header, '#!')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($header === "\x7FELF") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function bootstrapNode(V5Server $server): string
|
||||
{
|
||||
return 'v5-server-'.($server->uuid ?: $server->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function detectExistingBootstrap(V5Server $server, string $sshConfigLocation): array
|
||||
{
|
||||
$result = Process::timeout(15)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$this->bootstrapNode($server),
|
||||
'if [ -f '.escapeshellarg(self::BOOTSTRAP_MARKER_PATH).' ]; then cat '.escapeshellarg(self::BOOTSTRAP_MARKER_PATH).'; fi',
|
||||
]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$output = trim($result->output());
|
||||
|
||||
if ($output === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($output, true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $marker
|
||||
*/
|
||||
private function adoptExistingBootstrap(V5Cluster $cluster, V5Server $server, array $marker, string $sshConfigLocation): void
|
||||
{
|
||||
$bootstrapNode = $this->bootstrapNode($server);
|
||||
$serverUuid = is_string($marker['server_uuid'] ?? null) ? $marker['server_uuid'] : null;
|
||||
$updates = [
|
||||
'wireguard_management_ip' => is_string($marker['wireguard_management_ip'] ?? null) ? $marker['wireguard_management_ip'] : $server->wireguard_management_ip,
|
||||
'wireguard_public_key' => is_string($marker['wireguard_public_key'] ?? null) ? $marker['wireguard_public_key'] : $server->wireguard_public_key,
|
||||
'coold_version' => is_string($marker['coold_version'] ?? null) && trim($marker['coold_version']) !== '' ? trim($marker['coold_version']) : $cluster->coold_version,
|
||||
'container_subnets' => is_array($marker['container_subnets'] ?? null) ? $marker['container_subnets'] : $server->container_subnets,
|
||||
'has_coold' => true,
|
||||
'status' => ServerStatus::Installed->value,
|
||||
'last_bootstrap_status' => 'succeeded',
|
||||
'last_bootstrap_output' => 'Adopted existing Coolify bootstrap state for this cluster.',
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
'last_bootstrapped_at' => now(),
|
||||
];
|
||||
|
||||
if ($serverUuid !== null && ! V5Server::query()->where('uuid', $serverUuid)->whereKeyNot($server->id)->exists()) {
|
||||
$updates['uuid'] = $serverUuid;
|
||||
}
|
||||
|
||||
$server->update($updates);
|
||||
$this->broadcastClusterUpdated($server);
|
||||
|
||||
$this->enrollCooldIntoFlux($server->fresh(), $sshConfigLocation, $bootstrapNode);
|
||||
$this->waitForFluxHostConnection($server->fresh());
|
||||
|
||||
if ($server->isIngress()) {
|
||||
StartCaddyIngress::run($server->fresh('privateKey'));
|
||||
}
|
||||
}
|
||||
|
||||
private function persistBootstrapAssignments(V5Cluster $cluster, V5Server $server, string $output, string $sshConfigLocation): void
|
||||
{
|
||||
$verifiedNode = $this->verifiedBootstrapNode($output, $server);
|
||||
$wireguardManagementIp = is_array($verifiedNode) && is_string($verifiedNode['wireguard_ip'] ?? null)
|
||||
? $verifiedNode['wireguard_ip']
|
||||
: null;
|
||||
$warnings = [];
|
||||
|
||||
if (! is_string($wireguardManagementIp) || $wireguardManagementIp === '') {
|
||||
$wireguardManagementIp = $this->readWireguardManagementIp($cluster, $server, $sshConfigLocation, $warnings);
|
||||
}
|
||||
|
||||
$wireguardPublicKey = $this->readWireguardPublicKey($cluster, $server, $sshConfigLocation, $warnings);
|
||||
$containerSubnets = $this->readContainerSubnets($cluster, $server, $sshConfigLocation, $warnings);
|
||||
$updates = [];
|
||||
|
||||
if ($wireguardManagementIp !== null && $wireguardManagementIp !== '') {
|
||||
$updates['wireguard_management_ip'] = $wireguardManagementIp;
|
||||
|
||||
if (! is_string($server->node_address) || $server->node_address === '' || $server->node_address === $server->host) {
|
||||
$updates['node_address'] = $wireguardManagementIp;
|
||||
}
|
||||
} else {
|
||||
$warnings[] = 'Warning: could not determine the WireGuard management IP from the CLI output.';
|
||||
}
|
||||
|
||||
if ($wireguardPublicKey !== null && $wireguardPublicKey !== '') {
|
||||
$updates['wireguard_public_key'] = $wireguardPublicKey;
|
||||
}
|
||||
|
||||
if ($containerSubnets !== []) {
|
||||
$updates['container_subnets'] = $containerSubnets;
|
||||
}
|
||||
|
||||
if ($warnings !== []) {
|
||||
$updates['last_bootstrap_output'] = str(trim($server->last_bootstrap_output."\n".implode("\n", $warnings)))
|
||||
->limit(20000)
|
||||
->toString();
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
$server->update($updates);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $warnings
|
||||
*/
|
||||
private function readWireguardManagementIp(V5Cluster $cluster, V5Server $server, string $sshConfigLocation, array &$warnings): ?string
|
||||
{
|
||||
$interface = escapeshellarg($cluster->wireguard_interface);
|
||||
$script = implode("\n", [
|
||||
"SUDO=''",
|
||||
'if [ "$(id -u)" != "0" ]; then SUDO=\'sudo\'; fi',
|
||||
"\$SUDO ip -4 -o addr show dev {$interface} | awk '{print \$4}' | cut -d/ -f1 | head -n1",
|
||||
]);
|
||||
|
||||
$result = Process::timeout(15)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$this->bootstrapNode($server),
|
||||
$script,
|
||||
]);
|
||||
|
||||
$ipAddress = trim($result->output());
|
||||
|
||||
if (! $result->successful() || filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) {
|
||||
$warnings[] = 'Warning: could not read the WireGuard management IP from the server.';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ipAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function verifiedBootstrapNode(string $output, V5Server $server): ?array
|
||||
{
|
||||
$decoded = $this->decodedBootstrapOutput($output);
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$verifiedNodes = data_get($decoded, 'verified');
|
||||
|
||||
if (! is_array($verifiedNodes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bootstrapNode = $this->bootstrapNode($server);
|
||||
|
||||
foreach ($verifiedNodes as $verifiedNode) {
|
||||
if (! is_array($verifiedNode)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$host = $verifiedNode['host'] ?? $verifiedNode['node'] ?? $verifiedNode['name'] ?? null;
|
||||
|
||||
if ($host === $bootstrapNode || $host === $server->uuid || $host === $server->name || $host === $server->host) {
|
||||
return $verifiedNode;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function decodedBootstrapOutput(string $output): ?array
|
||||
{
|
||||
$output = trim($output);
|
||||
|
||||
if ($output === '' || ! str_starts_with($output, '{')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($output, true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The CLI init JSON output does not currently report the installed coold
|
||||
* version, so fall back to the version the cluster asked the CLI to
|
||||
* install (`--coold-version`). If a future CLI adds a `coold_version` key
|
||||
* to its JSON output, prefer that.
|
||||
*/
|
||||
private function bootstrappedCooldVersion(V5Cluster $cluster, string $output): ?string
|
||||
{
|
||||
$reported = data_get($this->decodedBootstrapOutput($output), 'coold_version');
|
||||
|
||||
if (is_string($reported) && trim($reported) !== '') {
|
||||
return trim($reported);
|
||||
}
|
||||
|
||||
return $cluster->coold_version;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $warnings
|
||||
*/
|
||||
private function readWireguardPublicKey(V5Cluster $cluster, V5Server $server, string $sshConfigLocation, array &$warnings): ?string
|
||||
{
|
||||
$interface = escapeshellarg($cluster->wireguard_interface);
|
||||
$script = implode("\n", [
|
||||
"SUDO=''",
|
||||
'if [ "$(id -u)" != "0" ]; then SUDO=\'sudo\'; fi',
|
||||
"\$SUDO wg show {$interface} public-key",
|
||||
]);
|
||||
|
||||
$result = Process::timeout(15)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$this->bootstrapNode($server),
|
||||
$script,
|
||||
]);
|
||||
|
||||
$publicKey = trim($result->output());
|
||||
|
||||
if (! $result->successful() || $publicKey === '') {
|
||||
$warnings[] = 'Warning: could not read the WireGuard public key from the server.';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* The container subnets are allocated by the coolify CLI on the host; the podman
|
||||
* networks it creates are the source of truth, so read them back instead of
|
||||
* re-deriving the allocation locally.
|
||||
*
|
||||
* @param array<int, string> $warnings
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function readContainerSubnets(V5Cluster $cluster, V5Server $server, string $sshConfigLocation, array &$warnings): array
|
||||
{
|
||||
$namespaces = $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES;
|
||||
|
||||
if ($namespaces === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$namespaceArguments = collect($namespaces)
|
||||
->map(fn (string $namespace): string => escapeshellarg($namespace))
|
||||
->implode(' ');
|
||||
$script = implode("\n", [
|
||||
"SUDO=''",
|
||||
'if [ "$(id -u)" != "0" ]; then SUDO=\'sudo\'; fi',
|
||||
"for ns in {$namespaceArguments}; do",
|
||||
' printf \'%s=\' "$ns"',
|
||||
' $SUDO podman network inspect "coolify-${ns}-mesh" --format \'{{range .Subnets}}{{.Subnet}}{{end}}\' 2>/dev/null || true',
|
||||
' printf \'\n\'',
|
||||
'done',
|
||||
]);
|
||||
|
||||
$result = Process::timeout(30)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$this->bootstrapNode($server),
|
||||
$script,
|
||||
]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
$warnings[] = 'Warning: could not read the container subnets from the server.';
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$subnets = [];
|
||||
|
||||
foreach (preg_split('/\r?\n/', trim($result->output())) ?: [] as $line) {
|
||||
[$namespace, $subnet] = array_pad(explode('=', trim($line), 2), 2, null);
|
||||
|
||||
if (! is_string($namespace) || ! in_array($namespace, $namespaces, true) || ! $this->isIpv4Cidr($subnet)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$subnets[$namespace] = $subnet;
|
||||
}
|
||||
|
||||
if (count($subnets) !== count($namespaces)) {
|
||||
$warnings[] = 'Warning: could not read every container subnet from the server; the stored subnets may be incomplete.';
|
||||
}
|
||||
|
||||
return $subnets;
|
||||
}
|
||||
|
||||
private function isIpv4Cidr(?string $value): bool
|
||||
{
|
||||
if (! is_string($value) || ! str_contains($value, '/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$ip, $prefix] = explode('/', $value, 2);
|
||||
|
||||
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false
|
||||
&& ctype_digit($prefix)
|
||||
&& (int) $prefix <= 32;
|
||||
}
|
||||
|
||||
private function enrollCooldIntoFlux(V5Server $server, string $sshConfigLocation, ?string $bootstrapNode = null): void
|
||||
{
|
||||
$fluxUrl = trim((string) config('coold.flux_url', ''));
|
||||
|
||||
if ($fluxUrl === '') {
|
||||
throw new \RuntimeException('COOLIFY_COOLD_FLUX_URL is not configured, so the server cannot be enrolled into Flux. Set it and retry the bootstrap.');
|
||||
}
|
||||
|
||||
$jwtPath = trim((string) config('coold.flux_host_jwt_path', '/etc/coolify/host-jwt'));
|
||||
|
||||
if ($jwtPath === '') {
|
||||
$jwtPath = '/etc/coolify/host-jwt';
|
||||
}
|
||||
|
||||
$fluxUrl = str_replace(["\r", "\n"], '', $fluxUrl);
|
||||
$jwtPath = str_replace(["\r", "\n"], '', $jwtPath);
|
||||
$hostId = $server->fluxHostId();
|
||||
$token = app(AgentTokenIssuer::class)->issueForServer($server);
|
||||
$tokenArgument = $this->shellArg($token);
|
||||
$hostId = str_replace(["\r", "\n"], '', $hostId);
|
||||
$jwtPathArgument = $this->shellPathArg($jwtPath);
|
||||
$dropInDirectory = '/etc/systemd/system/coold.service.d';
|
||||
$dropInPath = "{$dropInDirectory}/10-flux.conf";
|
||||
$script = <<<SH
|
||||
set -e
|
||||
SUDO=''
|
||||
if [ "\$(id -u)" != "0" ]; then SUDO='sudo'; fi
|
||||
\$SUDO mkdir -p /etc/coolify {$dropInDirectory}
|
||||
printf %s {$tokenArgument} | \$SUDO tee {$jwtPathArgument} >/dev/null
|
||||
\$SUDO chmod 600 {$jwtPathArgument}
|
||||
cat <<'COOLIFY_FLUX_ENV' | \$SUDO tee {$dropInPath} >/dev/null
|
||||
[Service]
|
||||
Environment=COOLIFY_COOLD_FLUX_URL={$fluxUrl}
|
||||
Environment=COOLIFY_COOLD_HOST_ID={$hostId}
|
||||
Environment=COOLIFY_COOLD_HOST_JWT_PATH={$jwtPath}
|
||||
COOLIFY_FLUX_ENV
|
||||
\$SUDO systemctl daemon-reload
|
||||
\$SUDO systemctl restart coold.service
|
||||
SH;
|
||||
|
||||
$result = Process::timeout(60)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$bootstrapNode ?? $this->bootstrapNode($server),
|
||||
$script,
|
||||
]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
|
||||
throw new \RuntimeException(
|
||||
($output !== '' ? $output : 'Could not enroll coold into Flux.')
|
||||
."\nThe WireGuard mesh was created successfully; retrying this bootstrap is safe and will resume from Flux enrollment."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function waitForFluxHostConnection(V5Server $server): void
|
||||
{
|
||||
$timeoutSeconds = (int) config('flux.bootstrap_host_connection_timeout_seconds', 30);
|
||||
|
||||
if ($timeoutSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
throw new \RuntimeException('Server is missing its Flux host id after bootstrap.');
|
||||
}
|
||||
|
||||
$deadline = time() + $timeoutSeconds;
|
||||
$lastError = null;
|
||||
|
||||
do {
|
||||
try {
|
||||
app(FluxClient::class)->cooldLogs($hostId, 1);
|
||||
|
||||
return;
|
||||
} catch (\Throwable $exception) {
|
||||
$lastError = $exception->getMessage();
|
||||
sleep(1);
|
||||
}
|
||||
} while (time() < $deadline);
|
||||
|
||||
throw new \RuntimeException(
|
||||
'The server was bootstrapped, but coold did not connect to Flux in time. '
|
||||
.'Wait a moment and retry the bootstrap before deploying applications.'
|
||||
.($lastError !== null ? " Last Flux error: {$lastError}" : '')
|
||||
);
|
||||
}
|
||||
|
||||
private function shellArg(string $value): string
|
||||
{
|
||||
return escapeshellarg($value);
|
||||
}
|
||||
|
||||
private function shellPathArg(string $value): string
|
||||
{
|
||||
if (preg_match('/^[A-Za-z0-9_\/:.,@%+=-]+$/', $value) === 1) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $this->shellArg($value);
|
||||
}
|
||||
|
||||
private function writeBootstrapMarker(V5Cluster $cluster, V5Server $server, string $sshConfigLocation, ?string $cooldVersion = null): void
|
||||
{
|
||||
$payload = base64_encode(json_encode([
|
||||
'cluster_id' => $cluster->id,
|
||||
'cluster_uuid' => $cluster->uuid,
|
||||
'server_uuid' => $server->uuid,
|
||||
'wireguard_management_ip' => $server->wireguard_management_ip,
|
||||
'wireguard_public_key' => $server->wireguard_public_key,
|
||||
'coold_version' => $cooldVersion ?? $server->coold_version ?? $cluster->coold_version,
|
||||
'container_subnets' => $server->container_subnets ?? [],
|
||||
], JSON_THROW_ON_ERROR));
|
||||
|
||||
$result = Process::timeout(15)->run([
|
||||
'ssh',
|
||||
'-F',
|
||||
$sshConfigLocation,
|
||||
$this->bootstrapNode($server),
|
||||
"payload='{$payload}'; if [ \"$(id -u)\" = \"0\" ]; then mkdir -p /etc/coolify && printf %s \"$payload\" | base64 -d > ".escapeshellarg(self::BOOTSTRAP_MARKER_PATH)."; else sudo mkdir -p /etc/coolify && printf %s \"$payload\" | base64 -d | sudo tee ".escapeshellarg(self::BOOTSTRAP_MARKER_PATH).' >/dev/null; fi',
|
||||
]);
|
||||
|
||||
if (! $result->successful()) {
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
|
||||
throw new \RuntimeException('Could not write the bootstrap marker to the server: '.($output !== '' ? $output : 'the SSH command failed.'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, V5Server> $servers
|
||||
*/
|
||||
private function writeBootstrapSshConfig(Collection $servers, string $tempDirectory): string
|
||||
{
|
||||
$config = '';
|
||||
|
||||
$servers->each(function (V5Server $server) use (&$config, $tempDirectory): void {
|
||||
$keyLocation = "{$tempDirectory}/server-{$server->id}.key";
|
||||
file_put_contents($keyLocation, $server->privateKey->private_key);
|
||||
chmod($keyLocation, 0600);
|
||||
|
||||
$config .= implode("\n", [
|
||||
'Host '.$this->bootstrapNode($server),
|
||||
' HostName '.$server->host,
|
||||
' Port '.$server->ssh_port,
|
||||
' User '.$server->ssh_user,
|
||||
' IdentityFile '.$keyLocation,
|
||||
' IdentitiesOnly yes',
|
||||
' LogLevel ERROR',
|
||||
' StrictHostKeyChecking no',
|
||||
' UserKnownHostsFile /dev/null',
|
||||
' BatchMode yes',
|
||||
'',
|
||||
]);
|
||||
});
|
||||
|
||||
$sshConfigLocation = "{$tempDirectory}/ssh.config";
|
||||
file_put_contents($sshConfigLocation, $config);
|
||||
chmod($sshConfigLocation, 0600);
|
||||
|
||||
return $sshConfigLocation;
|
||||
}
|
||||
|
||||
private function deleteDirectory(string $directory): void
|
||||
{
|
||||
if (! is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (scandir($directory) ?: [] as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = "{$directory}/{$file}";
|
||||
|
||||
if (is_dir($path)) {
|
||||
$this->deleteDirectory($path);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@unlink($path);
|
||||
}
|
||||
|
||||
@rmdir($directory);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, V5Server> $servers
|
||||
*/
|
||||
private function wireguardListenPortOverrides(Collection $servers): string
|
||||
{
|
||||
return $servers
|
||||
->filter(fn (V5Server $server) => $server->wireguard_listen_port_override !== null)
|
||||
->map(fn (V5Server $server) => $this->bootstrapNode($server).'='.$server->wireguard_listen_port_override)
|
||||
->implode(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, V5Server> $servers
|
||||
*/
|
||||
private function wireguardEndpointOverrides(Collection $servers): string
|
||||
{
|
||||
return $servers
|
||||
->filter(fn (V5Server $server) => $server->wireguard_endpoint_override !== null)
|
||||
->map(fn (V5Server $server) => $this->bootstrapNode($server).'='.$server->wireguard_endpoint_override)
|
||||
->implode(',');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\V5\Application\DeployNginxApplication;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class V5DeployApplicationJob implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
/**
|
||||
* Job timeout plus a safety margin so a lost lock can never block
|
||||
* redeploys of the same application forever.
|
||||
*/
|
||||
public int $uniqueFor = 360;
|
||||
|
||||
public function __construct(public int $applicationId) {}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return (string) $this->applicationId;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$application = V5Application::query()->find($this->applicationId);
|
||||
|
||||
if (! $application instanceof V5Application) {
|
||||
return;
|
||||
}
|
||||
|
||||
DeployNginxApplication::run($application);
|
||||
}
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
V5Application::query()->find($this->applicationId)?->update([
|
||||
'status' => 'failed',
|
||||
'status_message' => str($exception?->getMessage() ?? 'The deploy job failed.')->limit(10000)->toString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Enums\V5\ContainerState;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ContainerStatus;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use App\Support\V5\StatusObservation;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Actively reconciles one v5 server against the containers coold actually
|
||||
* reports. V5 status is normally push-only (coold -> flux -> webhook), so a
|
||||
* dropped webhook leaves rows stale forever; this job is the pull-based
|
||||
* safety net scheduled via V5ReconcileServersJob.
|
||||
*/
|
||||
class V5ReconcileServerStateJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Reconcile runs on its own queue so the 5-minute fleet fan-out (one
|
||||
* blocking flux call per server) can never starve user-triggered deploys
|
||||
* and bootstraps sharing the default queue. Set via onQueue() in the
|
||||
* constructor rather than a `$queue` property redeclaration, which the
|
||||
* Queueable trait already defines (redeclaring with a default is an
|
||||
* incompatible property composition and fatals on PHP 8.5).
|
||||
*/
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 120;
|
||||
|
||||
public function __construct(public int $serverId)
|
||||
{
|
||||
$this->onQueue('v5-reconcile');
|
||||
}
|
||||
|
||||
public function handle(FluxClient $fluxClient): void
|
||||
{
|
||||
$server = V5Server::query()->find($this->serverId);
|
||||
|
||||
if (! $server instanceof V5Server) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if ($hostId === '') {
|
||||
Log::warning('V5 reconcile skipped: server is missing a Flux host id.', ['server_id' => $server->id]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// The moment we query coold is the observation time for every row this
|
||||
// pass writes; a webhook that lands with a newer observation while this
|
||||
// (possibly delayed) snapshot is processed must win the watermark.
|
||||
$observedAt = CarbonImmutable::now();
|
||||
|
||||
try {
|
||||
$containers = collect($fluxClient->listContainers($hostId));
|
||||
} catch (\Throwable $exception) {
|
||||
$this->markServerUnreachable($server, $exception, $observedAt);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markServerReachable($server, $containers->count(), $observedAt);
|
||||
$this->refreshContainerStatuses($server, $containers, $observedAt);
|
||||
$this->reconcileApplications($server, $containers, $observedAt);
|
||||
}
|
||||
|
||||
private function markServerUnreachable(V5Server $server, \Throwable $exception, CarbonInterface $observedAt): void
|
||||
{
|
||||
Log::warning('V5 reconcile could not reach the server via flux.', [
|
||||
'server_id' => $server->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$attributes = [
|
||||
'last_status_check' => 'reconcile',
|
||||
'last_status_output' => str($exception->getMessage())->limit(1000)->toString(),
|
||||
'last_status_checked_at' => now(),
|
||||
];
|
||||
|
||||
if (! StatusObservation::isStale($observedAt, $server->status_observed_at, 'server status', ['server_id' => $server->id])) {
|
||||
// Only an installed server can degrade to unreachable; added or
|
||||
// failed servers keep their bootstrap-driven status.
|
||||
$attributes['status'] = $server->status === ServerStatus::Installed->value
|
||||
? ServerStatus::Unreachable->value
|
||||
: $server->status;
|
||||
$attributes['status_observed_at'] = $observedAt;
|
||||
}
|
||||
|
||||
$server->update($attributes);
|
||||
}
|
||||
|
||||
private function markServerReachable(V5Server $server, int $containerCount, CarbonInterface $observedAt): void
|
||||
{
|
||||
$attributes = [
|
||||
'last_status_check' => 'reconcile',
|
||||
'last_status_output' => "Reconciled {$containerCount} containers from coold.",
|
||||
'last_status_checked_at' => now(),
|
||||
];
|
||||
|
||||
if (! StatusObservation::isStale($observedAt, $server->status_observed_at, 'server status', ['server_id' => $server->id])) {
|
||||
$attributes['status'] = $server->status === ServerStatus::Unreachable->value
|
||||
? ServerStatus::Installed->value
|
||||
: $server->status;
|
||||
$attributes['status_observed_at'] = $observedAt;
|
||||
}
|
||||
|
||||
$server->update($attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, mixed> $containers
|
||||
*/
|
||||
private function refreshContainerStatuses(V5Server $server, Collection $containers, CarbonInterface $observedAt): void
|
||||
{
|
||||
$containers->each(function (mixed $container) use ($server, $observedAt): void {
|
||||
if (! is_array($container) || ! is_string($container['id'] ?? null) || $container['id'] === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = ContainerStatus::query()
|
||||
->where('server_id', $server->id)
|
||||
->where('container_id', $container['id'])
|
||||
->first();
|
||||
|
||||
if (StatusObservation::isStale($observedAt, $existing?->status_observed_at, 'container status', [
|
||||
'server_id' => $server->id,
|
||||
'container_id' => $container['id'],
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
|
||||
ContainerStatus::query()->updateOrCreate([
|
||||
'server_id' => $server->id,
|
||||
'container_id' => $container['id'],
|
||||
], [
|
||||
'team_id' => $server->team_id,
|
||||
'container_name' => is_string($container['name'] ?? null) ? $container['name'] : null,
|
||||
'image' => is_string($container['image'] ?? null) ? $container['image'] : null,
|
||||
'status' => $this->containerState($container, ContainerState::class),
|
||||
'status_message' => 'Container state reconciled from coold.',
|
||||
'status_observed_at' => $observedAt,
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, mixed> $containers
|
||||
*/
|
||||
private function reconcileApplications(V5Server $server, Collection $containers, CarbonInterface $observedAt): void
|
||||
{
|
||||
V5Application::query()
|
||||
->where('server_id', $server->id)
|
||||
->get()
|
||||
->each(function (V5Application $application) use ($containers, $observedAt): void {
|
||||
try {
|
||||
$this->reconcileApplication($application, $containers, $observedAt);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('V5 reconcile failed for an application.', [
|
||||
'application_id' => $application->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, mixed> $containers
|
||||
*/
|
||||
private function reconcileApplication(V5Application $application, Collection $containers, CarbonInterface $observedAt): void
|
||||
{
|
||||
$container = $containers->first(function (mixed $container) use ($application): bool {
|
||||
return is_array($container)
|
||||
&& (($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;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'status' => ApplicationStatus::Exited->value,
|
||||
'status_observed_at' => $observedAt,
|
||||
];
|
||||
|
||||
if ($application->status !== ApplicationStatus::Exited->value) {
|
||||
$attributes['status_message'] = 'Container not found on server during reconcile.';
|
||||
}
|
||||
|
||||
$application->update($attributes);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (StatusObservation::isStale($observedAt, $application->status_observed_at, 'application status', ['application_id' => $application->id])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = $this->containerState($container, ApplicationStatus::class);
|
||||
|
||||
$attributes = [
|
||||
'status' => $status,
|
||||
'status_observed_at' => $observedAt,
|
||||
'runtime_container_id' => is_string($container['id'] ?? null) && $container['id'] !== ''
|
||||
? $container['id']
|
||||
: $application->runtime_container_id,
|
||||
];
|
||||
|
||||
// Only write status_message when the status actually changes: the
|
||||
// status column is what a viewer cares about, and a constant message
|
||||
// would otherwise fire a broadcast + full re-serialization every cycle.
|
||||
if ($status !== $application->status) {
|
||||
$attributes['status_message'] = 'Container state reconciled from coold.';
|
||||
}
|
||||
|
||||
$application->update($attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $container
|
||||
* @param class-string<ApplicationStatus|ContainerState> $enumClass
|
||||
*/
|
||||
private function containerState(array $container, string $enumClass): string
|
||||
{
|
||||
$state = $container['state'] ?? null;
|
||||
$raw = is_string($state) && $state !== '' ? $state : null;
|
||||
|
||||
return StatusObservation::normalize($raw, $enumClass) ?? $enumClass::Unknown->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ContainerStatus;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Scheduled fan-out for the v5 reconciliation loop: dispatches one
|
||||
* V5ReconcileServerStateJob per managed server and prunes container status
|
||||
* rows that no webhook has refreshed within the TTL.
|
||||
*/
|
||||
class V5ReconcileServersJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public const CONTAINER_STATUS_TTL_HOURS = 24;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 60;
|
||||
|
||||
/**
|
||||
* Reconcile runs on its own queue so the 5-minute fleet fan-out can never
|
||||
* starve user-triggered deploys and bootstraps sharing the default queue.
|
||||
* Set via onQueue() rather than a `$queue` property redeclaration, which
|
||||
* the Queueable trait already defines (redeclaring with a default is an
|
||||
* incompatible property composition and fatals on PHP 8.5).
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->onQueue('v5-reconcile');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$this->dispatchReconcileJobs();
|
||||
$this->pruneContainerStatuses();
|
||||
}
|
||||
|
||||
private function dispatchReconcileJobs(): void
|
||||
{
|
||||
V5Server::query()
|
||||
// Unreachable servers stay in the loop so a recovered node is
|
||||
// restored to installed by its next successful reconcile.
|
||||
->whereIn('status', [ServerStatus::Installed->value, ServerStatus::Unreachable->value])
|
||||
->where('has_coold', true)
|
||||
->get()
|
||||
->each(function (V5Server $server): void {
|
||||
try {
|
||||
V5ReconcileServerStateJob::dispatch($server->id);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('V5 reconcile dispatch failed for a server.', [
|
||||
'server_id' => $server->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function pruneContainerStatuses(): void
|
||||
{
|
||||
$cutoff = now()->subHours(self::CONTAINER_STATUS_TTL_HOURS);
|
||||
$liveContainerIds = V5Application::query()
|
||||
->whereNotNull('runtime_container_id')
|
||||
->pluck('runtime_container_id')
|
||||
->all();
|
||||
|
||||
ContainerStatus::query()
|
||||
->where(function ($query) use ($cutoff): void {
|
||||
$query
|
||||
->where('last_seen_at', '<', $cutoff)
|
||||
->orWhere(function ($query) use ($cutoff): void {
|
||||
$query->whereNull('last_seen_at')->where('created_at', '<', $cutoff);
|
||||
})
|
||||
->orWhereNotIn('server_id', V5Server::query()->select('id'));
|
||||
})
|
||||
->when($liveContainerIds !== [], fn ($query) => $query->whereNotIn('container_id', $liveContainerIds))
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\V5\Server\PushHostAgentToken;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Re-mints and delivers a fresh host JWT for one managed server before its
|
||||
* on-disk token expires.
|
||||
*
|
||||
* RPC-FIRST, SSH-FALLBACK: the new token is delivered over the live coold RPC
|
||||
* stream by default (Laravel -> flux UDS -> coold's `host.jwt.set` command),
|
||||
* because that reuses the already authenticated flux<->coold channel and works
|
||||
* while the CURRENT token is still valid — which is exactly when rotation runs
|
||||
* (at ~12h remaining, well before the 24h exp). Only if the RPC push fails (the
|
||||
* host's stream is down because its token already lapsed, flux rejects the verb,
|
||||
* a timeout, etc.) do we fall back to the SSH push, which recovers a node whose
|
||||
* token already expired and whose stream is therefore gone.
|
||||
*
|
||||
* PUSH-THEN-PERSIST: the new token is delivered to the host FIRST, and the
|
||||
* server's jti/expires_at are only advanced AFTER a successful delivery via
|
||||
* EITHER path. If both delivery paths fail the DB is left untouched, so the old
|
||||
* expires_at keeps the server inside the dispatcher's rotation window and the
|
||||
* next cycle simply retries — we never advance the watermark on a token the
|
||||
* host never received (which would strand the host on the expiring old token
|
||||
* until it fully lapsed).
|
||||
*
|
||||
* NO-REVOKE-ON-ROTATION: the previously issued jti is intentionally NOT revoked
|
||||
* here. The old token is still legitimately valid until its own exp and coold
|
||||
* may still be connected on it; revoking it would risk cutting the live stream.
|
||||
* Revocation belongs to teardown/re-home (RemoveBootstrapMarker), not routine
|
||||
* rotation — the old token simply ages out on its own exp.
|
||||
*/
|
||||
class V5RotateAgentTokenJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 60;
|
||||
|
||||
/**
|
||||
* Rotation shares the reconcile queue so the hourly fleet fan-out can never
|
||||
* starve user-triggered deploys and bootstraps on the default queue. Set via
|
||||
* onQueue() rather than a `$queue` property redeclaration, which the
|
||||
* Queueable trait already defines (redeclaring with a default is an
|
||||
* incompatible property composition and fatals on PHP 8.5).
|
||||
*/
|
||||
public function __construct(public int $serverId)
|
||||
{
|
||||
$this->onQueue('v5-reconcile');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$server = V5Server::query()->with('privateKey')->find($this->serverId);
|
||||
|
||||
if (! $server instanceof V5Server) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->isEligible($server)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if ($hostId === '') {
|
||||
Log::warning('V5 token rotation skipped: server is missing a Flux host id.', ['server_id' => $server->id]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ttl = (int) config('flux.host_token_ttl');
|
||||
$jti = (string) Str::uuid();
|
||||
|
||||
$token = app(AgentTokenIssuer::class)->issue($hostId, null, $ttl, [
|
||||
'jti' => $jti,
|
||||
'team_id' => (string) $server->team_id,
|
||||
'cluster_id' => (string) $server->cluster_id,
|
||||
'server_id' => $hostId,
|
||||
'wireguard_management_ip' => (string) $server->wireguard_management_ip,
|
||||
]);
|
||||
|
||||
$delivery = $this->deliverToken($server, $hostId, $token);
|
||||
|
||||
if ($delivery === null) {
|
||||
Log::warning('V5 token rotation could not deliver the new host token; leaving the existing token in place.', [
|
||||
'server_id' => $server->id,
|
||||
'host' => $server->host,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$server->update([
|
||||
'agent_token_jti' => $jti,
|
||||
'agent_token_expires_at' => now()->addSeconds($ttl),
|
||||
]);
|
||||
|
||||
Log::debug('V5 token rotation delivered a fresh host token.', [
|
||||
'server_id' => $server->id,
|
||||
'delivery' => $delivery,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver the freshly minted token to the host, preferring the live coold
|
||||
* RPC stream and falling back to the SSH push on any RPC failure.
|
||||
*
|
||||
* @return 'rpc'|'ssh'|null The path that succeeded, or null if both failed.
|
||||
*/
|
||||
private function deliverToken(V5Server $server, string $hostId, string $token): ?string
|
||||
{
|
||||
try {
|
||||
app(FluxClient::class)->pushHostToken($hostId, $token);
|
||||
|
||||
return 'rpc';
|
||||
} catch (\Throwable $exception) {
|
||||
Log::info('V5 token rotation RPC push failed; falling back to SSH.', [
|
||||
'server_id' => $server->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (PushHostAgentToken::run($server, $token)) {
|
||||
return 'ssh';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function isEligible(V5Server $server): bool
|
||||
{
|
||||
return $server->status === ServerStatus::Installed->value
|
||||
&& (bool) $server->has_coold
|
||||
&& $server->last_bootstrapped_at !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Scheduled fan-out for host JWT rotation: dispatches one V5RotateAgentTokenJob
|
||||
* per managed server whose on-disk token is missing or within the configured
|
||||
* refresh threshold of expiry, so a fresh token is always on disk before the
|
||||
* current one lapses.
|
||||
*/
|
||||
class V5RotateAgentTokensJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 60;
|
||||
|
||||
/**
|
||||
* Rotation shares the reconcile queue so the hourly fleet fan-out can never
|
||||
* starve user-triggered deploys and bootstraps on the default queue. Set via
|
||||
* onQueue() rather than a `$queue` property redeclaration, which the
|
||||
* Queueable trait already defines (redeclaring with a default is an
|
||||
* incompatible property composition and fatals on PHP 8.5).
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->onQueue('v5-reconcile');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$threshold = now()->addSeconds((int) config('flux.host_token_refresh_threshold'));
|
||||
|
||||
V5Server::query()
|
||||
->where('status', ServerStatus::Installed->value)
|
||||
->where('has_coold', true)
|
||||
->whereNotNull('last_bootstrapped_at')
|
||||
->where(function ($query) use ($threshold): void {
|
||||
$query
|
||||
->whereNull('agent_token_expires_at')
|
||||
->orWhere('agent_token_expires_at', '<', $threshold);
|
||||
})
|
||||
->get()
|
||||
->each(function (V5Server $server): void {
|
||||
try {
|
||||
V5RotateAgentTokenJob::dispatch($server->id);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('V5 token rotation dispatch failed for a server.', [
|
||||
'server_id' => $server->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\V5\Application\DestroyNginxApplication;
|
||||
use App\Actions\V5\Proxy\StopCaddyIngress;
|
||||
use App\Actions\V5\Server\RemoveBootstrapMarker;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Best-effort, on-host teardown for a team that is being deleted.
|
||||
*
|
||||
* Deleting a v4 Team DB-cascades every v5_servers / v5_applications /
|
||||
* v5_container_statuses / v5_resource_connections row (see the
|
||||
* cascadeOnDelete() foreign keys in the v5 migrations) WITHOUT running the
|
||||
* app-level teardown that the per-resource destroy flows use. That would leave
|
||||
* orphaned podman containers, a running Caddy ingress, the WireGuard mesh and
|
||||
* coold itself alive on every host with no DB record left to reach them.
|
||||
*
|
||||
* This job mirrors the ServerController::destroy / ApplicationController::destroy
|
||||
* teardown sequence for every v5 server owned by the team:
|
||||
* 1. remove each application's container (DestroyNginxApplication),
|
||||
* 2. stop the Caddy ingress on ingress servers (StopCaddyIngress),
|
||||
* 3. remove the on-host bootstrap identity — marker, host-jwt and Flux
|
||||
* drop-in (RemoveBootstrapMarker).
|
||||
*
|
||||
* Because the cascade deletes the servers, applications and private keys the
|
||||
* moment the team is gone, the payload is captured at dispatch time (from the
|
||||
* Team `deleting` hook, which fires BEFORE the rows vanish) as plain arrays,
|
||||
* including the SSH private-key material needed to reach each host. The job
|
||||
* rebuilds in-memory, non-persisted models from that payload so it can reuse
|
||||
* the exact same actions without touching the (now missing) DB rows.
|
||||
*
|
||||
* BEST-EFFORT / LIMITATIONS: teardown is best-effort. Each host and each action
|
||||
* is guarded so a single unreachable host can never abort teardown of the other
|
||||
* hosts, and the team deletion itself never fails because of teardown. The
|
||||
* payload is fully self-contained (host, SSH creds/private key, applications,
|
||||
* token jti + expiry), so a framework-level queue retry is safe — every step is
|
||||
* idempotent (podman rm -f / ingress stop / rm -f are all no-ops when the target
|
||||
* is already gone).
|
||||
*
|
||||
* RESIDUAL LIMITATION: a host that is unreachable at team-deletion time orphans
|
||||
* its containers, ingress and mesh PERMANENTLY — the DB rows the reconcilers key
|
||||
* off are gone, so there is no later reconciliation. The single operator-facing
|
||||
* signal is the `Log::error` emitted at the end of handle() listing the host
|
||||
* ids/hosts that could not be torn down; grep for "v5 team teardown incomplete"
|
||||
* to find them.
|
||||
*/
|
||||
class V5TeardownTeamJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* A small retry budget: the payload is self-contained and every teardown
|
||||
* step is idempotent, so retrying an unreachable host is safe. There is no
|
||||
* point retrying forever — the host may simply be gone.
|
||||
*/
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $servers Self-contained per-server teardown payload captured before the cascade.
|
||||
*/
|
||||
public function __construct(
|
||||
public int $teamId,
|
||||
public array $servers,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Collect the team's v5 servers (with their applications and SSH key
|
||||
* material) into a self-contained payload and dispatch the teardown job.
|
||||
*
|
||||
* Must be called from the Team `deleting` hook, while the rows still exist.
|
||||
* Returns without dispatching when the team owns no v5 servers.
|
||||
*/
|
||||
public static function dispatchForTeam(Team $team): void
|
||||
{
|
||||
// Guard against contexts where the v5 tables do not exist (e.g. v4-only
|
||||
// schemas) so team deletion is never broken by this teardown.
|
||||
if (! Schema::hasTable('v5_servers')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$servers = V5Server::query()
|
||||
->where('team_id', $team->id)
|
||||
->with('privateKey')
|
||||
->get();
|
||||
|
||||
if ($servers->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$applicationsByServer = V5Application::query()
|
||||
->where('team_id', $team->id)
|
||||
->whereNotNull('server_id')
|
||||
->get()
|
||||
->groupBy('server_id');
|
||||
|
||||
$payload = $servers->map(function (V5Server $server) use ($applicationsByServer): array {
|
||||
return [
|
||||
'id' => $server->id,
|
||||
'uuid' => $server->uuid,
|
||||
'name' => $server->name,
|
||||
'host' => $server->host,
|
||||
'ssh_user' => $server->ssh_user,
|
||||
'ssh_port' => (int) $server->ssh_port,
|
||||
'node_address' => $server->node_address,
|
||||
'wireguard_management_ip' => $server->wireguard_management_ip,
|
||||
'is_ingress' => (bool) $server->is_ingress,
|
||||
'ingress_type' => $server->ingress_type,
|
||||
'status' => $server->status,
|
||||
'last_bootstrapped_at' => $server->last_bootstrapped_at?->toISOString(),
|
||||
// Captured before the cascade removes the row so the job can
|
||||
// revoke the host token after the DB rows are gone.
|
||||
'agent_token_jti' => $server->agent_token_jti,
|
||||
'agent_token_expires_at' => $server->agent_token_expires_at?->toISOString(),
|
||||
// Encrypted at rest on the model; needed to SSH into the host.
|
||||
'private_key' => $server->privateKey instanceof PrivateKey ? $server->privateKey->private_key : null,
|
||||
'applications' => ($applicationsByServer[$server->id] ?? collect())
|
||||
->map(fn (V5Application $application): array => [
|
||||
'id' => $application->id,
|
||||
'container_name' => $application->container_name,
|
||||
'runtime_container_id' => $application->runtime_container_id,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
})->all();
|
||||
|
||||
self::dispatch($team->id, $payload);
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$incompleteHosts = [];
|
||||
|
||||
foreach ($this->servers as $serverPayload) {
|
||||
if (! $this->teardownServer($serverPayload)) {
|
||||
$incompleteHosts[] = [
|
||||
'server_id' => $serverPayload['id'] ?? null,
|
||||
'host' => $serverPayload['host'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Teardown is best-effort and never fails the job (an unreachable host
|
||||
// must not abort the others), so this is the single operator-facing
|
||||
// signal that some hosts could not be reached and may now hold orphaned
|
||||
// containers/mesh with no DB row left to reconcile them.
|
||||
if ($incompleteHosts !== []) {
|
||||
Log::error('v5 team teardown incomplete — '.count($incompleteHosts).' host(s) may have orphaned containers/mesh', [
|
||||
'team_id' => $this->teamId,
|
||||
'hosts' => $incompleteHosts,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down a single host. Returns false when any on-host teardown step
|
||||
* (container removal, ingress stop, bootstrap-marker removal) failed, so the
|
||||
* caller can surface the host as potentially orphaned. Never throws: a
|
||||
* single unreachable host must not abort teardown of the other hosts.
|
||||
*
|
||||
* @param array<string, mixed> $serverPayload
|
||||
*/
|
||||
private function teardownServer(array $serverPayload): bool
|
||||
{
|
||||
$server = $this->reconstructServer($serverPayload);
|
||||
$serverId = $serverPayload['id'] ?? null;
|
||||
$host = $serverPayload['host'] ?? null;
|
||||
$succeeded = true;
|
||||
|
||||
foreach ($serverPayload['applications'] ?? [] as $applicationPayload) {
|
||||
try {
|
||||
$application = $this->reconstructApplication($applicationPayload, $server);
|
||||
DestroyNginxApplication::run($application);
|
||||
} catch (\Throwable $exception) {
|
||||
$succeeded = false;
|
||||
Log::warning('V5 team teardown: failed to remove application container', [
|
||||
'team_id' => $this->teamId,
|
||||
'server_id' => $serverId,
|
||||
'host' => $host,
|
||||
'container_name' => $applicationPayload['container_name'] ?? null,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($server->isIngress() && $server->status === ServerStatus::Installed->value) {
|
||||
try {
|
||||
StopCaddyIngress::run($server);
|
||||
} catch (\Throwable $exception) {
|
||||
$succeeded = false;
|
||||
Log::warning('V5 team teardown: failed to stop Caddy ingress', [
|
||||
'team_id' => $this->teamId,
|
||||
'server_id' => $serverId,
|
||||
'host' => $host,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (($serverPayload['last_bootstrapped_at'] ?? null) !== null) {
|
||||
try {
|
||||
if (! RemoveBootstrapMarker::run($server)) {
|
||||
$succeeded = false;
|
||||
Log::warning('V5 team teardown: could not remove on-host bootstrap identity over SSH', [
|
||||
'team_id' => $this->teamId,
|
||||
'server_id' => $serverId,
|
||||
'host' => $host,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$succeeded = false;
|
||||
Log::warning('V5 team teardown: bootstrap marker removal threw', [
|
||||
'team_id' => $this->teamId,
|
||||
'server_id' => $serverId,
|
||||
'host' => $host,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Revocation is best-effort and independent of the on-host cleanup: a
|
||||
// failed flux push does not mean the host is orphaned, so it never flips
|
||||
// $succeeded (it is logged separately inside AgentTokenIssuer::revoke).
|
||||
$this->revokeAgentTokenIfSupported($server, $serverId, $host);
|
||||
|
||||
return $succeeded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a non-persisted V5Server (with its private key relation
|
||||
* pre-set) so the teardown actions never hit the deleted DB rows.
|
||||
*
|
||||
* @param array<string, mixed> $serverPayload
|
||||
*/
|
||||
private function reconstructServer(array $serverPayload): V5Server
|
||||
{
|
||||
$server = new V5Server;
|
||||
$server->forceFill([
|
||||
'id' => $serverPayload['id'] ?? null,
|
||||
'uuid' => $serverPayload['uuid'] ?? null,
|
||||
'name' => $serverPayload['name'] ?? null,
|
||||
'host' => $serverPayload['host'] ?? null,
|
||||
'ssh_user' => $serverPayload['ssh_user'] ?? null,
|
||||
'ssh_port' => $serverPayload['ssh_port'] ?? 22,
|
||||
'node_address' => $serverPayload['node_address'] ?? null,
|
||||
'wireguard_management_ip' => $serverPayload['wireguard_management_ip'] ?? null,
|
||||
'is_ingress' => (bool) ($serverPayload['is_ingress'] ?? false),
|
||||
'ingress_type' => $serverPayload['ingress_type'] ?? null,
|
||||
'status' => $serverPayload['status'] ?? null,
|
||||
'agent_token_jti' => $serverPayload['agent_token_jti'] ?? null,
|
||||
'agent_token_expires_at' => $serverPayload['agent_token_expires_at'] ?? null,
|
||||
]);
|
||||
// Non-persisted: StopCaddyIngress / the actions must not try to update a
|
||||
// row that the cascade already removed.
|
||||
$server->exists = false;
|
||||
|
||||
$privateKeyMaterial = $serverPayload['private_key'] ?? null;
|
||||
if (is_string($privateKeyMaterial) && $privateKeyMaterial !== '') {
|
||||
$privateKey = new PrivateKey;
|
||||
$privateKey->forceFill(['private_key' => $privateKeyMaterial]);
|
||||
$server->setRelation('privateKey', $privateKey);
|
||||
} else {
|
||||
$server->setRelation('privateKey', null);
|
||||
}
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $applicationPayload
|
||||
*/
|
||||
private function reconstructApplication(array $applicationPayload, V5Server $server): V5Application
|
||||
{
|
||||
$application = new V5Application;
|
||||
$application->forceFill([
|
||||
'id' => $applicationPayload['id'] ?? null,
|
||||
'container_name' => $applicationPayload['container_name'] ?? null,
|
||||
'runtime_container_id' => $applicationPayload['runtime_container_id'] ?? null,
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
$application->exists = false;
|
||||
$application->setRelation('server', $server);
|
||||
|
||||
return $application;
|
||||
}
|
||||
|
||||
/**
|
||||
* If a coold-side agent-token revocation ever lands on AgentTokenIssuer,
|
||||
* call it best-effort. Guarded so this job never hard-depends on a method
|
||||
* that may not exist yet.
|
||||
*/
|
||||
private function revokeAgentTokenIfSupported(V5Server $server, mixed $serverId, mixed $host): void
|
||||
{
|
||||
if (! method_exists(AgentTokenIssuer::class, 'revokeForServer')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
app(AgentTokenIssuer::class)->revokeForServer($server);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('V5 team teardown: agent token revocation failed', [
|
||||
'team_id' => $this->teamId,
|
||||
'server_id' => $serverId,
|
||||
'host' => $host,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Events\V5CanvasResourceUpdated;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Application extends V5Model
|
||||
{
|
||||
protected $table = 'v5_applications';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'team_id',
|
||||
'project_id',
|
||||
'environment_id',
|
||||
'server_id',
|
||||
'created_by_user_id',
|
||||
'name',
|
||||
'image',
|
||||
'container_name',
|
||||
'status',
|
||||
'status_message',
|
||||
'status_observed_at',
|
||||
'runtime_container_id',
|
||||
'mesh_namespace',
|
||||
'ingress_enabled',
|
||||
'internal_port',
|
||||
'canvas_x',
|
||||
'canvas_y',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'status' => ApplicationStatus::Creating->value,
|
||||
'mesh_namespace' => 'default',
|
||||
'ingress_enabled' => false,
|
||||
'canvas_x' => 0,
|
||||
'canvas_y' => 0,
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updated(function (self $application): void {
|
||||
if ($application->wasChanged(['status', 'status_message', 'runtime_container_id'])) {
|
||||
DB::afterCommit(fn () => V5CanvasResourceUpdated::dispatch($application->team_id, $application->id));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status_observed_at' => 'datetime',
|
||||
'ingress_enabled' => 'boolean',
|
||||
'internal_port' => 'integer',
|
||||
'canvas_x' => 'integer',
|
||||
'canvas_y' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function environment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Environment::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function domains(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApplicationDomain::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ApplicationDomain extends V5Model
|
||||
{
|
||||
protected $table = 'v5_application_domains';
|
||||
|
||||
protected bool $hasUuidColumn = false;
|
||||
|
||||
protected $fillable = [
|
||||
'application_id',
|
||||
'domain',
|
||||
];
|
||||
|
||||
public function application(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Application::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Cluster extends V5Model
|
||||
{
|
||||
protected $table = 'v5_clusters';
|
||||
|
||||
/**
|
||||
* Single source of truth for cluster defaults: `$attributes` below is
|
||||
* built from these consts, and the column defaults in
|
||||
* 2026_06_16_130649_v5_create_clusters_table mirror them (kept there for
|
||||
* historical rows only — update both when changing a default).
|
||||
*/
|
||||
public const DEFAULT_WIREGUARD_INTERFACE = 'wg0';
|
||||
|
||||
public const DEFAULT_WIREGUARD_MANAGEMENT_POOL = '100.64.0.0/16';
|
||||
|
||||
public const DEFAULT_WIREGUARD_LISTEN_PORT = 51820;
|
||||
|
||||
public const DEFAULT_CONTAINER_NETWORK_POOL = '10.210.0.0/16';
|
||||
|
||||
public const DEFAULT_CONTAINER_NETWORK_PREFIX = 24;
|
||||
|
||||
public const DEFAULT_NAMESPACES = ['default'];
|
||||
|
||||
public const DEFAULT_COOLD_VERSION = 'nightly';
|
||||
|
||||
public const DEFAULT_CORROSION_VERSION = 'v1.0.0';
|
||||
|
||||
public const DEFAULT_CORROSION_GOSSIP_PORT = 8787;
|
||||
|
||||
public const DEFAULT_CORROSION_API_PORT = 8080;
|
||||
|
||||
public const DEFAULT_BUILDER_CAPACITY = 2;
|
||||
|
||||
public const DEFAULT_BUILDER_CPU_QUOTA = '200%';
|
||||
|
||||
public const DEFAULT_BUILDER_MEMORY_MAX = '2G';
|
||||
|
||||
public const DEFAULT_BUILDER_TIMEOUT_SECS = 1800;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'team_id',
|
||||
'created_by_user_id',
|
||||
'name',
|
||||
'description',
|
||||
'wireguard_interface',
|
||||
'wireguard_management_pool',
|
||||
'wireguard_listen_port',
|
||||
'container_network_pool',
|
||||
'container_network_prefix',
|
||||
'namespaces',
|
||||
'default_deny_containers',
|
||||
'coold_version',
|
||||
'corrosion_version',
|
||||
'corrosion_gossip_port',
|
||||
'corrosion_api_port',
|
||||
'builder_enabled',
|
||||
'builder_capacity',
|
||||
'builder_cpu_quota',
|
||||
'builder_memory_max',
|
||||
'builder_timeout_secs',
|
||||
'last_cli_action',
|
||||
'last_cli_status',
|
||||
'last_cli_summary',
|
||||
'last_cli_ran_at',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'wireguard_interface' => self::DEFAULT_WIREGUARD_INTERFACE,
|
||||
'wireguard_management_pool' => self::DEFAULT_WIREGUARD_MANAGEMENT_POOL,
|
||||
'wireguard_listen_port' => self::DEFAULT_WIREGUARD_LISTEN_PORT,
|
||||
'container_network_pool' => self::DEFAULT_CONTAINER_NETWORK_POOL,
|
||||
'container_network_prefix' => self::DEFAULT_CONTAINER_NETWORK_PREFIX,
|
||||
'default_deny_containers' => true,
|
||||
'coold_version' => self::DEFAULT_COOLD_VERSION,
|
||||
'corrosion_version' => self::DEFAULT_CORROSION_VERSION,
|
||||
'corrosion_gossip_port' => self::DEFAULT_CORROSION_GOSSIP_PORT,
|
||||
'corrosion_api_port' => self::DEFAULT_CORROSION_API_PORT,
|
||||
'builder_enabled' => true,
|
||||
'builder_capacity' => self::DEFAULT_BUILDER_CAPACITY,
|
||||
'builder_cpu_quota' => self::DEFAULT_BUILDER_CPU_QUOTA,
|
||||
'builder_memory_max' => self::DEFAULT_BUILDER_MEMORY_MAX,
|
||||
'builder_timeout_secs' => self::DEFAULT_BUILDER_TIMEOUT_SECS,
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'namespaces' => 'array',
|
||||
'default_deny_containers' => 'boolean',
|
||||
'builder_enabled' => 'boolean',
|
||||
'last_cli_ran_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function servers(): HasMany
|
||||
{
|
||||
return $this->hasMany(Server::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ContainerStatus extends V5Model
|
||||
{
|
||||
protected $table = 'v5_container_statuses';
|
||||
|
||||
protected bool $hasUuidColumn = false;
|
||||
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
'server_id',
|
||||
'container_id',
|
||||
'container_name',
|
||||
'image',
|
||||
'status',
|
||||
'status_message',
|
||||
'status_observed_at',
|
||||
'last_seen_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status_observed_at' => 'datetime',
|
||||
'last_seen_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class ResourceConnection extends V5Model
|
||||
{
|
||||
protected $table = 'v5_resource_connections';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'team_id',
|
||||
'project_id',
|
||||
'environment_id',
|
||||
'resource_one_type',
|
||||
'resource_one_id',
|
||||
'resource_two_type',
|
||||
'resource_two_id',
|
||||
'resource_pair_key',
|
||||
'created_by_user_id',
|
||||
];
|
||||
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function environment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Environment::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function resourceOne(): MorphTo
|
||||
{
|
||||
return $this->morphTo('resource_one');
|
||||
}
|
||||
|
||||
public function resourceTwo(): MorphTo
|
||||
{
|
||||
return $this->morphTo('resource_two');
|
||||
}
|
||||
|
||||
public function rules(): HasMany
|
||||
{
|
||||
return $this->hasMany(ResourceConnectionRule::class, 'connection_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class ResourceConnectionRule extends V5Model
|
||||
{
|
||||
protected $table = 'v5_resource_connection_rules';
|
||||
|
||||
protected bool $hasUuidColumn = false;
|
||||
|
||||
protected $fillable = [
|
||||
'connection_id',
|
||||
'source_resource_type',
|
||||
'source_resource_id',
|
||||
'target_resource_type',
|
||||
'target_resource_id',
|
||||
'protocol',
|
||||
'port',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'protocol' => 'tcp',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'port' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function connection(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ResourceConnection::class, 'connection_id');
|
||||
}
|
||||
|
||||
public function sourceResource(): MorphTo
|
||||
{
|
||||
return $this->morphTo('source_resource');
|
||||
}
|
||||
|
||||
public function targetResource(): MorphTo
|
||||
{
|
||||
return $this->morphTo('target_resource');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* A host-agent JWT that has been revoked (typically on server destroy/re-home).
|
||||
*
|
||||
* flux does not yet consult this list (see AgentTokenIssuer::revoke docblock);
|
||||
* it exists so the Laravel side owns the data and API needed for revocation the
|
||||
* moment flux gains a revocation check.
|
||||
*/
|
||||
class RevokedAgentToken extends V5Model
|
||||
{
|
||||
protected bool $hasUuidColumn = false;
|
||||
|
||||
protected $table = 'v5_revoked_agent_tokens';
|
||||
|
||||
protected $fillable = [
|
||||
'jti',
|
||||
'server_id',
|
||||
'revoked_at',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'revoked_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Server, $this>
|
||||
*/
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use App\Enums\V5\IngressStatus;
|
||||
use App\Events\V5CanvasResourceUpdated;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Server extends V5Model
|
||||
{
|
||||
protected $table = 'v5_servers';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'team_id',
|
||||
'cluster_id',
|
||||
'created_by_user_id',
|
||||
'private_key_id',
|
||||
'name',
|
||||
'host',
|
||||
'ssh_user',
|
||||
'ssh_port',
|
||||
'status',
|
||||
'status_observed_at',
|
||||
'ingress_type',
|
||||
'ingress_status',
|
||||
'capabilities',
|
||||
'has_coold',
|
||||
'is_ingress',
|
||||
'builder_enabled',
|
||||
'builder_capacity',
|
||||
'builder_cpu_quota',
|
||||
'node_address',
|
||||
'wireguard_listen_port_override',
|
||||
'wireguard_endpoint_override',
|
||||
'wireguard_management_ip',
|
||||
'wireguard_public_key',
|
||||
'coold_version',
|
||||
'agent_token_jti',
|
||||
'agent_token_expires_at',
|
||||
'container_subnets',
|
||||
'canvas_x',
|
||||
'canvas_y',
|
||||
'last_bootstrapped_at',
|
||||
'last_bootstrap_action',
|
||||
'last_bootstrap_status',
|
||||
'last_bootstrap_output',
|
||||
'last_bootstrap_ran_at',
|
||||
'last_status_check',
|
||||
'last_status_output',
|
||||
'last_status_checked_at',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updated(function (self $server): void {
|
||||
if (
|
||||
! $server->wasChanged('status')
|
||||
&& ! $server->wasChanged('ingress_type')
|
||||
&& ! $server->wasChanged('ingress_status')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($server->wasChanged('status') && $server->cluster_id !== null) {
|
||||
DB::afterCommit(fn () => V5ClusterUpdated::dispatch($server->team_id, $server->cluster_id));
|
||||
}
|
||||
|
||||
if ($server->wasChanged('status')) {
|
||||
DB::afterCommit(fn () => V5CanvasResourceUpdated::dispatch(
|
||||
$server->team_id,
|
||||
null,
|
||||
$server->isIngress() ? $server->id : null,
|
||||
$server->id,
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($server->isIngress()) {
|
||||
DB::afterCommit(fn () => V5CanvasResourceUpdated::dispatch($server->team_id, null, $server->id));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'has_coold' => 'boolean',
|
||||
'is_ingress' => 'boolean',
|
||||
'builder_enabled' => 'boolean',
|
||||
'container_subnets' => 'array',
|
||||
'canvas_x' => 'integer',
|
||||
'canvas_y' => 'integer',
|
||||
'status_observed_at' => 'datetime',
|
||||
'agent_token_expires_at' => 'datetime',
|
||||
'last_bootstrapped_at' => 'datetime',
|
||||
'last_bootstrap_ran_at' => 'datetime',
|
||||
'last_status_checked_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function fluxHostId(): string
|
||||
{
|
||||
return (string) $this->uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual attribute kept for wire-format compatibility: capabilities are
|
||||
* stored as the indexed has_coold / is_ingress booleans, but reads and
|
||||
* writes of `capabilities` keep working with the historical string array.
|
||||
* Unknown capability names are dropped on write.
|
||||
*
|
||||
* The dropped `capabilities` column intentionally stays in `$fillable`:
|
||||
* call sites still mass-assign it, and this mutator maps those writes
|
||||
* onto the boolean columns.
|
||||
*/
|
||||
protected function capabilities(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => array_values(array_filter([
|
||||
$this->has_coold ? 'coold' : null,
|
||||
$this->is_ingress ? 'ingress' : null,
|
||||
])),
|
||||
set: fn (?array $capabilities) => [
|
||||
'has_coold' => in_array('coold', $capabilities ?? [], true),
|
||||
'is_ingress' => in_array('ingress', $capabilities ?? [], true),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function hasCapability(string $capability): bool
|
||||
{
|
||||
return match ($capability) {
|
||||
'coold' => (bool) $this->has_coold,
|
||||
'ingress' => (bool) $this->is_ingress,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function withCapability(string $capability): array
|
||||
{
|
||||
return collect($this->capabilities)
|
||||
->push($capability)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function withoutCapability(string $capability): array
|
||||
{
|
||||
return collect($this->capabilities)
|
||||
->reject(fn (string $existingCapability) => $existingCapability === $capability)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function isIngress(): bool
|
||||
{
|
||||
return (bool) $this->is_ingress;
|
||||
}
|
||||
|
||||
public function ingressStatus(): string
|
||||
{
|
||||
return $this->ingress_status ?? IngressStatus::Unknown->value;
|
||||
}
|
||||
|
||||
public function ingressType(): string
|
||||
{
|
||||
return $this->ingress_type ?? 'caddy';
|
||||
}
|
||||
|
||||
public function cluster(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cluster::class);
|
||||
}
|
||||
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function privateKey(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PrivateKey::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\V5;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
abstract class V5Model extends Model
|
||||
{
|
||||
/**
|
||||
* Whether the model's table has a `uuid` column. Models without one (set
|
||||
* this to false there) skip public-id generation and route on the primary
|
||||
* key instead.
|
||||
*/
|
||||
protected bool $hasUuidColumn = true;
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return $this->hasUuidColumn ? 'uuid' : $this->getKeyName();
|
||||
}
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function (self $model): void {
|
||||
if ($model->hasUuidColumn && ! $model->getAttribute('uuid')) {
|
||||
$model->setAttribute('uuid', $model->newUniquePublicId());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a public id, regenerating (up to three candidates) when one is
|
||||
* already taken. A concurrent insert between this exists() check and our
|
||||
* own insert can still collide; the unique index then rejects the insert,
|
||||
* which is an acceptable residual race for these cheap, retryable writes.
|
||||
*/
|
||||
protected function newUniquePublicId(): string
|
||||
{
|
||||
$attempts = 0;
|
||||
|
||||
do {
|
||||
$candidate = $this->newPublicIdCandidate();
|
||||
$attempts++;
|
||||
} while (
|
||||
$attempts < 3
|
||||
&& $this->newModelQuery()->where('uuid', $candidate)->exists()
|
||||
);
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
protected function newPublicIdCandidate(): string
|
||||
{
|
||||
return new_public_id();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies\V5;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Application;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class ApplicationPolicy
|
||||
{
|
||||
public function create(User $user, Team $team): Response
|
||||
{
|
||||
return $user->isAdminOfTeam($team->id)
|
||||
? Response::allow()
|
||||
: Response::deny('You do not have permission to manage applications in this team.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the application within the current team.
|
||||
*
|
||||
* Read-only diagnostics (deploy status, container logs) are available to any
|
||||
* member of the owning team; apps from other teams stay hidden as a 404.
|
||||
*/
|
||||
public function view(User $user, Application $application, Team $team): Response
|
||||
{
|
||||
return $this->belongsToTeam($application, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the application within the current team.
|
||||
*/
|
||||
public function update(User $user, Application $application, Team $team): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $application, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the application's ingress configuration.
|
||||
*/
|
||||
public function updateIngress(User $user, Application $application, Team $team): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $application, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the application within the current team.
|
||||
*/
|
||||
public function delete(User $user, Application $application, Team $team): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $application, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the team scoping check first (mismatch stays hidden as a 404) and
|
||||
* only then the role check (403 for members on their own team's app).
|
||||
*/
|
||||
private function allowIfAdminAndScoped(User $user, Application $application, Team $team): Response
|
||||
{
|
||||
$scope = $this->belongsToTeam($application, $team);
|
||||
|
||||
if ($scope->denied()) {
|
||||
return $scope;
|
||||
}
|
||||
|
||||
return $user->isAdminOfTeam($team->id)
|
||||
? Response::allow()
|
||||
: Response::deny('You do not have permission to manage applications in this team.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Applications outside the current team must stay invisible, so
|
||||
* mismatches deny as not found instead of forbidden.
|
||||
*/
|
||||
private function belongsToTeam(Application $application, Team $team): Response
|
||||
{
|
||||
return $application->team_id === $team->id
|
||||
? Response::allow()
|
||||
: Response::denyAsNotFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies\V5;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Cluster;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class ClusterPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view the cluster within the current team.
|
||||
* Read-only, so gated on team membership alone.
|
||||
*/
|
||||
public function view(User $user, Cluster $cluster, Team $team): Response
|
||||
{
|
||||
return $this->belongsToTeam($cluster, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create a cluster in the current team.
|
||||
* There is no model to scope yet, so this is a pure role gate.
|
||||
*/
|
||||
public function create(User $user, Team $team): Response
|
||||
{
|
||||
return $this->allowIfAdmin($user, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the cluster within the current team.
|
||||
*/
|
||||
public function delete(User $user, Cluster $cluster, Team $team): Response
|
||||
{
|
||||
$scope = $this->belongsToTeam($cluster, $team);
|
||||
|
||||
if ($scope->denied()) {
|
||||
return $scope;
|
||||
}
|
||||
|
||||
return $this->allowIfAdmin($user, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Members may read but not mutate; only admins/owners of the team pass.
|
||||
*/
|
||||
private function allowIfAdmin(User $user, Team $team): Response
|
||||
{
|
||||
return $user->isAdminOfTeam($team->id)
|
||||
? Response::allow()
|
||||
: Response::deny('You do not have permission to manage clusters in this team.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clusters outside the current team must stay invisible, so mismatches
|
||||
* deny as not found instead of forbidden.
|
||||
*/
|
||||
private function belongsToTeam(Cluster $cluster, Team $team): Response
|
||||
{
|
||||
return $cluster->team_id === $team->id
|
||||
? Response::allow()
|
||||
: Response::denyAsNotFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies\V5;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class ResourceConnectionPolicy
|
||||
{
|
||||
public function create(User $user, Team $team): Response
|
||||
{
|
||||
return $user->isAdminOfTeam($team->id)
|
||||
? Response::allow()
|
||||
: Response::deny('You do not have permission to manage resource connections in this team.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the connection within the current team.
|
||||
*/
|
||||
public function update(User $user, ResourceConnection $connection, Team $team): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $connection, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the connection within the current team.
|
||||
*/
|
||||
public function delete(User $user, ResourceConnection $connection, Team $team): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $connection, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the team scoping check first (mismatch stays hidden as a 404) and
|
||||
* only then the role check (403 for members on their own team's connection).
|
||||
*/
|
||||
private function allowIfAdminAndScoped(User $user, ResourceConnection $connection, Team $team): Response
|
||||
{
|
||||
$scope = $this->belongsToTeam($connection, $team);
|
||||
|
||||
if ($scope->denied()) {
|
||||
return $scope;
|
||||
}
|
||||
|
||||
return $user->isAdminOfTeam($team->id)
|
||||
? Response::allow()
|
||||
: Response::deny('You do not have permission to manage resource connections in this team.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Connections outside the current team must stay invisible, so
|
||||
* mismatches deny as not found instead of forbidden.
|
||||
*/
|
||||
private function belongsToTeam(ResourceConnection $connection, Team $team): Response
|
||||
{
|
||||
return $connection->team_id === $team->id
|
||||
? Response::allow()
|
||||
: Response::denyAsNotFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies\V5;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Cluster;
|
||||
use App\Models\V5\Server;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class ServerPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can add a server to the cluster within the
|
||||
* current team. Denies as forbidden (not "not found") to preserve the
|
||||
* historical 403 on cluster/team mismatch, and requires an admin/owner
|
||||
* role to mutate cluster infrastructure.
|
||||
*/
|
||||
public function create(User $user, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
if ($cluster->team_id !== $team->id) {
|
||||
return Response::deny();
|
||||
}
|
||||
|
||||
return $this->allowIfAdmin($user, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the server within the current team.
|
||||
*/
|
||||
public function update(User $user, Server $server, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $server, $team, $cluster);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the server within the current team.
|
||||
*/
|
||||
public function delete(User $user, Server $server, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $server, $team, $cluster);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can run a connectivity check against the server.
|
||||
*/
|
||||
public function check(User $user, Server $server, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $server, $team, $cluster);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can bootstrap the server.
|
||||
*/
|
||||
public function bootstrap(User $user, Server $server, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $server, $team, $cluster);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restart coold over SSH.
|
||||
*/
|
||||
public function restartCoold(User $user, Server $server, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
return $this->allowIfAdminAndScoped($user, $server, $team, $cluster);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view server diagnostics (coold logs,
|
||||
* corrosion tables, firewall rules). Read-only, so gated on team
|
||||
* membership alone.
|
||||
*/
|
||||
public function viewDiagnostics(User $user, Server $server, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
return $this->belongsToClusterInTeam($server, $team, $cluster);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can move the server's Caddy ingress card on
|
||||
* the canvas. Non-ingress servers must stay invisible on the canvas, and
|
||||
* moving a card mutates persisted layout so it requires an admin/owner.
|
||||
*/
|
||||
public function updateCanvasPosition(User $user, Server $server, Team $team): Response
|
||||
{
|
||||
if (! ($server->team_id === $team->id && $server->isIngress())) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $this->allowIfAdmin($user, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the team/cluster scoping check first (mismatch stays hidden as a 404)
|
||||
* and only then the role check (403 for members on their own team's server).
|
||||
*/
|
||||
private function allowIfAdminAndScoped(User $user, Server $server, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
$scope = $this->belongsToClusterInTeam($server, $team, $cluster);
|
||||
|
||||
if ($scope->denied()) {
|
||||
return $scope;
|
||||
}
|
||||
|
||||
return $this->allowIfAdmin($user, $team);
|
||||
}
|
||||
|
||||
/**
|
||||
* Members may read but not mutate; only admins/owners of the team pass.
|
||||
*/
|
||||
private function allowIfAdmin(User $user, Team $team): Response
|
||||
{
|
||||
return $user->isAdminOfTeam($team->id)
|
||||
? Response::allow()
|
||||
: Response::deny('You do not have permission to manage servers in this team.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Servers outside the current team (or outside the addressed cluster)
|
||||
* must stay invisible, so mismatches deny as not found.
|
||||
*/
|
||||
private function belongsToClusterInTeam(Server $server, Team $team, Cluster $cluster): Response
|
||||
{
|
||||
return $cluster->team_id === $team->id
|
||||
&& $server->team_id === $team->id
|
||||
&& $server->cluster_id === $cluster->id
|
||||
? Response::allow()
|
||||
: Response::denyAsNotFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Flux;
|
||||
|
||||
use App\Models\V5\RevokedAgentToken;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Mints the per-host ES256 JWT that authorizes a coold host agent against flux.
|
||||
*
|
||||
* Capability scoping: by default the token carries the EXPLICIT list of
|
||||
* primitive capability strings coold advertises (config('flux.host_capabilities'),
|
||||
* mirroring coold/coold/src/grpc/client.rs:204-231) rather than the
|
||||
* `host-agent:default` wildcard profile. flux intersects the jwt `caps` with
|
||||
* coold's advertised set (flux/src/main.rs:128-141), so the effective power is
|
||||
* unchanged, but the token no longer depends on flux's
|
||||
* `capability_profile_authorizes_all` wildcard bypass (main.rs:124-126).
|
||||
*
|
||||
* @see config/flux.php for the capability list, escape hatch, TTL and kid config.
|
||||
*/
|
||||
class AgentTokenIssuer
|
||||
{
|
||||
public const DEFAULT_PROFILE = 'host-agent:default';
|
||||
|
||||
private const TTL_FLOOR_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Mint a host JWT.
|
||||
*
|
||||
* @param array<int, string>|null $capabilities Explicit caps; null resolves the configured default set (or escape-hatch profile).
|
||||
* @param int|null $ttl Lifetime in seconds; null resolves config('flux.host_token_ttl'). Clamped to a 60s floor.
|
||||
* @param array<string, mixed> $extraClaims Extra claims merged in (a `jti` here is honored, otherwise one is generated).
|
||||
*/
|
||||
public function issue(string $hostId, ?array $capabilities = null, ?int $ttl = null, array $extraClaims = []): string
|
||||
{
|
||||
if ($hostId === '') {
|
||||
throw new RuntimeException('Flux host id is required.');
|
||||
}
|
||||
|
||||
$privateKeyPath = config('flux.jwt_private_key_path');
|
||||
|
||||
if (! is_string($privateKeyPath) || $privateKeyPath === '' || ! File::isReadable($privateKeyPath)) {
|
||||
throw new RuntimeException("Flux JWT private key not found at {$privateKeyPath}.");
|
||||
}
|
||||
|
||||
$this->assertPrivateKeyPermissions($privateKeyPath);
|
||||
|
||||
$capabilities ??= $this->defaultCapabilities();
|
||||
$ttl ??= (int) config('flux.host_token_ttl', 3600);
|
||||
|
||||
$jti = $extraClaims['jti'] ?? (string) Str::uuid();
|
||||
unset($extraClaims['jti']);
|
||||
|
||||
$now = time();
|
||||
$keyId = (string) config('flux.jwt_kid', 'flux-default');
|
||||
|
||||
return JWT::encode(array_merge($extraClaims, [
|
||||
'sub' => $hostId,
|
||||
'aud' => 'coold',
|
||||
'caps' => $this->normalizeCapabilities($capabilities),
|
||||
'jti' => $jti,
|
||||
'iat' => $now,
|
||||
'exp' => $now + max(self::TTL_FLOOR_SECONDS, $ttl),
|
||||
]), File::get($privateKeyPath), 'ES256', $keyId !== '' ? $keyId : null);
|
||||
}
|
||||
|
||||
public function issueForServer(V5Server $server, ?int $ttl = null): string
|
||||
{
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if ($hostId === '') {
|
||||
throw new RuntimeException('Server is missing a valid Flux host id.');
|
||||
}
|
||||
|
||||
$jti = (string) Str::uuid();
|
||||
$ttl ??= (int) config('flux.host_token_ttl', 3600);
|
||||
|
||||
// team_id/cluster_id/server_id are minted as STRINGS: flux deserializes
|
||||
// the `team_id` claim as a string (coold/flux/src/auth.rs Claims), and
|
||||
// rejects the whole token with a JSON type error if it arrives as a JSON
|
||||
// integer. Keep the sibling ids string-typed for consistency.
|
||||
$token = $this->issue($hostId, $this->defaultCapabilities(), $ttl, [
|
||||
'jti' => $jti,
|
||||
'team_id' => (string) $server->team_id,
|
||||
'cluster_id' => (string) $server->cluster_id,
|
||||
'server_id' => $hostId,
|
||||
'wireguard_management_ip' => (string) $server->wireguard_management_ip,
|
||||
]);
|
||||
|
||||
// Persist the freshly issued jti (so a later destroy/re-home knows which
|
||||
// token to revoke) and its expiry (so the scheduled rotation loop knows
|
||||
// when to re-mint). Use a targeted update keyed by id so this neither
|
||||
// inserts an unsaved model nor flushes unrelated dirty attributes, and
|
||||
// does not depend on the Server model's $fillable.
|
||||
if ($server->exists) {
|
||||
$expiresAt = now()->addSeconds(max(self::TTL_FLOOR_SECONDS, $ttl));
|
||||
|
||||
V5Server::query()->whereKey($server->getKey())->update([
|
||||
'agent_token_jti' => $jti,
|
||||
'agent_token_expires_at' => $expiresAt,
|
||||
]);
|
||||
$server->setAttribute('agent_token_jti', $jti);
|
||||
$server->setAttribute('agent_token_expires_at', $expiresAt);
|
||||
$server->syncOriginalAttribute('agent_token_jti');
|
||||
$server->syncOriginalAttribute('agent_token_expires_at');
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the server's currently-issued host token jti as revoked AND push
|
||||
* the revocation to flux so it rejects the jti at verify immediately.
|
||||
*
|
||||
* flux now consults a revocation denylist (flux/src/auth.rs `is_revoked`,
|
||||
* fed by `POST /v1/tokens/revoke` on the flux UDS), and Laravel pushes to it
|
||||
* here. The local `RevokedAgentToken` record remains the source of truth
|
||||
* Laravel owns; the flux push is best-effort — if flux is unreachable the
|
||||
* revocation is logged and the local record still stands, with the short TTL
|
||||
* and hourly rotation bounding the exposure until flux is reachable again.
|
||||
*/
|
||||
public function revoke(V5Server $server): void
|
||||
{
|
||||
$jti = $server->agent_token_jti;
|
||||
|
||||
if (! is_string($jti) || $jti === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$expiresAt = $server->agent_token_expires_at;
|
||||
$expiresAtUnix = $expiresAt instanceof \DateTimeInterface ? $expiresAt->getTimestamp() : null;
|
||||
|
||||
RevokedAgentToken::query()->updateOrCreate(
|
||||
['jti' => $jti],
|
||||
[
|
||||
'server_id' => $server->id,
|
||||
'revoked_at' => now(),
|
||||
'expires_at' => $expiresAt,
|
||||
]
|
||||
);
|
||||
|
||||
// Best-effort: a destroy/teardown must never fail because flux is down.
|
||||
try {
|
||||
app(FluxClient::class)->revokeToken($jti, $expiresAtUnix);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Failed to push agent token revocation to Flux.', [
|
||||
'server_id' => $server->id,
|
||||
'jti' => $jti,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($server->exists) {
|
||||
V5Server::query()->whereKey($server->getKey())->update(['agent_token_jti' => null]);
|
||||
$server->setAttribute('agent_token_jti', null);
|
||||
$server->syncOriginalAttribute('agent_token_jti');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the server's currently-issued host token. Alias of {@see revoke()}
|
||||
* kept as the name the team-teardown job resolves via `method_exists`.
|
||||
*/
|
||||
public function revokeForServer(V5Server $server): void
|
||||
{
|
||||
$this->revoke($server);
|
||||
}
|
||||
|
||||
public function isRevoked(string $jti): bool
|
||||
{
|
||||
if ($jti === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return RevokedAgentToken::query()->where('jti', $jti)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* The default capability set for production host tokens: the explicit
|
||||
* advertised primitive list, unless the emergency escape hatch profile is
|
||||
* configured (then that single profile is minted instead).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function defaultCapabilities(): array
|
||||
{
|
||||
$profile = config('flux.host_capability_profile');
|
||||
|
||||
if (is_string($profile) && trim($profile) !== '') {
|
||||
return [trim($profile)];
|
||||
}
|
||||
|
||||
$configured = config('flux.host_capabilities');
|
||||
|
||||
if (is_array($configured) && $configured !== []) {
|
||||
return array_values($configured);
|
||||
}
|
||||
|
||||
return [self::DEFAULT_PROFILE];
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn (but do not hard-fail — that could break existing installs) when the
|
||||
* private key file is readable by group/other or is not owner-readable. The
|
||||
* key should be generated 0600, e.g.:
|
||||
* openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \
|
||||
* -out storage/app/flux/jwt.priv && chmod 600 storage/app/flux/jwt.priv
|
||||
*/
|
||||
private function assertPrivateKeyPermissions(string $path): void
|
||||
{
|
||||
$perms = @fileperms($path);
|
||||
|
||||
if ($perms === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mode = $perms & 0777;
|
||||
|
||||
if (($mode & 0077) !== 0 || ($mode & 0400) === 0) {
|
||||
Log::warning('Flux JWT private key has insecure permissions.', [
|
||||
'path' => $path,
|
||||
'mode' => sprintf('%04o', $mode),
|
||||
'expected' => '0600',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $capabilities
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeCapabilities(array $capabilities): array
|
||||
{
|
||||
$normalized = collect($capabilities)
|
||||
->map(fn (string $capability) => trim($capability))
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($normalized === []) {
|
||||
return [self::DEFAULT_PROFILE];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Flux;
|
||||
|
||||
use App\Exceptions\V5\UnsupportedCooldVerb;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class FluxClient
|
||||
{
|
||||
/**
|
||||
* @return array<int, array{id?: string, name?: string, image?: string, state?: string, networks?: array<int, string>}>
|
||||
*/
|
||||
public function listContainers(string $hostId): array
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'containers.list',
|
||||
]);
|
||||
|
||||
$data = $payload['data'] ?? [];
|
||||
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
public function pullImage(string $hostId, string $image): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'images.pull',
|
||||
'reference' => $image,
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'Image pulled.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $spec
|
||||
*/
|
||||
public function createContainer(string $hostId, array $spec): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'containers.create',
|
||||
...$spec,
|
||||
]);
|
||||
$data = $payload['data'] ?? [];
|
||||
$id = is_array($data) && is_string($data['id'] ?? null) ? $data['id'] : '';
|
||||
|
||||
if ($id === '') {
|
||||
throw new RuntimeException('Flux did not return a container id.');
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function startContainer(string $hostId, string $id): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'containers.start',
|
||||
'id' => $id,
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'Container started.');
|
||||
}
|
||||
|
||||
public function stopContainer(string $hostId, string $id, int $timeoutSeconds = 10): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'containers.stop',
|
||||
'id' => $id,
|
||||
'timeout_seconds' => max(0, $timeoutSeconds),
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'Container stopped.');
|
||||
}
|
||||
|
||||
public function removeContainer(string $hostId, string $id, bool $force = false): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'containers.delete',
|
||||
'id' => $id,
|
||||
'force' => $force,
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'Container removed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function inspectContainer(string $hostId, string $id): array
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'containers.inspect',
|
||||
'id' => $id,
|
||||
]);
|
||||
$data = $payload['data'] ?? [];
|
||||
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name: string, config: string}> $apps
|
||||
*/
|
||||
public function applyIngress(string $hostId, string $kind, string $config, array $apps = [], string $meshNetwork = 'coolify-default-mesh'): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'ingress.apply',
|
||||
'kind' => $kind,
|
||||
'config' => $config,
|
||||
'apps' => $apps,
|
||||
'mesh_network' => $meshNetwork,
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'Ingress applied.');
|
||||
}
|
||||
|
||||
public function stopIngress(string $hostId, string $kind): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'ingress.stop',
|
||||
'kind' => $kind,
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'Ingress stopped.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: string, namespace: string, src: string, dst: string, proto: string, port: int} $rule
|
||||
*/
|
||||
public function applyFirewallRule(string $hostId, array $rule): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'firewall.allow',
|
||||
...$rule,
|
||||
]);
|
||||
|
||||
$data = $payload['data'] ?? [];
|
||||
$id = is_array($data) && is_string($data['id'] ?? null) ? $data['id'] : '';
|
||||
|
||||
return $id !== '' ? $id : $this->output($payload, 'Firewall rule applied.');
|
||||
}
|
||||
|
||||
public function revokeFirewallRule(string $hostId, string $id): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'firewall.revoke',
|
||||
'id' => $id,
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'Firewall rule removed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id?: string, namespace?: string, src?: string, dst?: string, proto?: string, port?: int}>
|
||||
*/
|
||||
public function listFirewallRules(string $hostId, string $namespace = ''): array
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'firewall.list',
|
||||
'namespace' => $namespace,
|
||||
]);
|
||||
|
||||
$data = $payload['data'] ?? [];
|
||||
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
public function cooldLogs(string $hostId, int $tail = 200): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'coold.logs',
|
||||
'tail' => max(1, min($tail, 1000)),
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'No coold logs returned.');
|
||||
}
|
||||
|
||||
public function containerLogs(string $hostId, string $containerId, int $tail = 200): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'containers.logs',
|
||||
'id' => $containerId,
|
||||
'tail' => max(1, min($tail, 1000)),
|
||||
'stdout' => true,
|
||||
'stderr' => true,
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'No container logs returned.');
|
||||
}
|
||||
|
||||
public function corrosionTables(string $hostId, int $limit = 200): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'corrosion.tables',
|
||||
'limit' => max(1, min($limit, 1000)),
|
||||
]);
|
||||
|
||||
return $this->output($payload, '{"limit":200,"tables":[]}');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a freshly minted host JWT to the node over the live coold RPC
|
||||
* stream (flux gates the `host.jwt.set` capability; the token must carry
|
||||
* it). Preferred over the SSH push because it reuses the already
|
||||
* authenticated flux<->coold channel and works while the current token is
|
||||
* still valid — exactly the rotation window. Throws like the sibling
|
||||
* dispatch methods (host not connected / UnsupportedCooldVerb / generic
|
||||
* failure) so the caller can catch and fall back to SSH.
|
||||
*/
|
||||
public function pushHostToken(string $hostId, string $token): void
|
||||
{
|
||||
$this->dispatch($hostId, [
|
||||
'type' => 'host.jwt.set',
|
||||
'jwt' => $token,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a host token by its `jti` on the flux revocation store so flux
|
||||
* rejects it at verify immediately, instead of waiting for the token's TTL
|
||||
* to lapse (flux/src/unix_bridge.rs `POST /v1/tokens/revoke`,
|
||||
* flux/src/auth.rs `is_revoked`). The optional `expiresAt` (the token `exp`,
|
||||
* unix seconds) lets flux prune the denylist entry once it can no longer
|
||||
* matter.
|
||||
*
|
||||
* Best-effort like the sibling dispatch methods: throws a RuntimeException on
|
||||
* connection failure / timeout / non-2xx so the caller can catch and treat
|
||||
* an unreachable flux as non-fatal (the local revocation record still
|
||||
* stands and the short TTL + rotation bound the exposure).
|
||||
*/
|
||||
public function revokeToken(string $jti, ?int $expiresAt = null): void
|
||||
{
|
||||
if (trim($jti) === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$requestBody = ['jti' => $jti];
|
||||
|
||||
if ($expiresAt !== null) {
|
||||
$requestBody['expires_at'] = $expiresAt;
|
||||
}
|
||||
|
||||
$body = json_encode($requestBody, JSON_THROW_ON_ERROR);
|
||||
$response = $this->sendOverSocket('/v1/tokens/revoke', $body);
|
||||
$statusCode = $this->statusCode($response);
|
||||
|
||||
if ($statusCode < 200 || $statusCode >= 300) {
|
||||
$responseBody = $this->responseBody($response);
|
||||
$payload = $responseBody === '' ? null : json_decode($responseBody, true);
|
||||
|
||||
throw new RuntimeException(
|
||||
$this->errorMessage($payload, $responseBody) ?? "Flux token revocation returned HTTP {$statusCode}."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $command
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function dispatch(string $hostId, array $command): array
|
||||
{
|
||||
$body = json_encode([
|
||||
'host_id' => $hostId,
|
||||
'request_id' => (string) Str::uuid(),
|
||||
'command' => $command,
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
$response = $this->sendOverSocket('/v1/coold/dispatch', $body);
|
||||
|
||||
$statusCode = $this->statusCode($response);
|
||||
$responseBody = $this->responseBody($response);
|
||||
$payload = $responseBody === '' ? null : json_decode($responseBody, true);
|
||||
|
||||
if ($statusCode < 200 || $statusCode >= 300) {
|
||||
throw $this->dispatchException(
|
||||
$command,
|
||||
$statusCode,
|
||||
$this->errorMessage($payload, $responseBody) ?? "Flux dispatch returned HTTP {$statusCode}."
|
||||
);
|
||||
}
|
||||
|
||||
if (! is_array($payload)) {
|
||||
throw new RuntimeException('Flux dispatch returned an invalid response.');
|
||||
}
|
||||
|
||||
if (($payload['status'] ?? null) === 'error') {
|
||||
$message = is_string($payload['message'] ?? null) ? $payload['message'] : 'Flux dispatch failed.';
|
||||
|
||||
throw $this->dispatchException($command, $statusCode, $message);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a single HTTP/1.1 request over the flux Unix-domain socket and return
|
||||
* the raw response. Shared by every flux verb (coold dispatch, host token
|
||||
* rotation, token revocation) — only the request path and JSON body differ.
|
||||
*/
|
||||
private function sendOverSocket(string $path, string $body): string
|
||||
{
|
||||
$socketPath = config('flux.unix_socket_path');
|
||||
|
||||
if (! is_string($socketPath) || $socketPath === '') {
|
||||
throw new RuntimeException('Flux socket is not configured.');
|
||||
}
|
||||
|
||||
if (! file_exists($socketPath)) {
|
||||
throw new RuntimeException('Flux socket was not found.');
|
||||
}
|
||||
|
||||
$connectionTimeout = (float) config('flux.connection_timeout_seconds', 1.0);
|
||||
$dispatchTimeout = (float) config('flux.dispatch_timeout_seconds', 35.0);
|
||||
$stream = @stream_socket_client("unix://{$socketPath}", $errorCode, $errorMessage, $connectionTimeout);
|
||||
|
||||
if ($stream === false) {
|
||||
throw new RuntimeException($errorMessage ?: "Could not connect to Flux socket ({$errorCode}).");
|
||||
}
|
||||
|
||||
stream_set_timeout($stream, (int) ceil($dispatchTimeout));
|
||||
|
||||
fwrite($stream, implode("\r\n", [
|
||||
"POST {$path} HTTP/1.1",
|
||||
'Host: flux',
|
||||
'Accept: application/json',
|
||||
'Content-Type: application/json',
|
||||
'Content-Length: '.strlen($body),
|
||||
'Connection: close',
|
||||
'',
|
||||
$body,
|
||||
]));
|
||||
|
||||
$response = stream_get_contents($stream) ?: '';
|
||||
fclose($stream);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flux answers a verb the node's coold did not advertise with HTTP 501 and
|
||||
* the message "primitive <verb> is not supported by host" (coold repo:
|
||||
* flux/src/routing.rs:50-53, flux/src/unix_bridge.rs:227-245). Anything
|
||||
* else — including coold-side command failures relayed with their own
|
||||
* status code — is a generic dispatch failure.
|
||||
*
|
||||
* @param array<string, mixed> $command
|
||||
*/
|
||||
private function dispatchException(array $command, int $statusCode, string $message): RuntimeException
|
||||
{
|
||||
$verb = is_string($command['type'] ?? null) ? $command['type'] : 'unknown';
|
||||
|
||||
if ($statusCode === 501 || preg_match('/primitive .+ is not supported by host/i', $message) === 1) {
|
||||
return new UnsupportedCooldVerb($verb, $message);
|
||||
}
|
||||
|
||||
return new RuntimeException($message);
|
||||
}
|
||||
|
||||
private function statusCode(string $response): int
|
||||
{
|
||||
if ($response === '') {
|
||||
throw new RuntimeException('Flux did not return a response before the timeout. Check that coold is connected to Flux and try again.');
|
||||
}
|
||||
|
||||
if (preg_match('/^HTTP\/\d(?:\.\d)?\s+(\d{3})/', $response, $matches) !== 1) {
|
||||
throw new RuntimeException('Could not talk to Flux. Check that Flux is running in the Coolify container.');
|
||||
}
|
||||
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
private function responseBody(string $response): string
|
||||
{
|
||||
$position = strpos($response, "\r\n\r\n");
|
||||
|
||||
return $position === false ? '' : substr($response, $position + 4);
|
||||
}
|
||||
|
||||
private function errorMessage(mixed $payload, string $responseBody): ?string
|
||||
{
|
||||
if (is_array($payload) && is_string($payload['message'] ?? null) && $payload['message'] !== '') {
|
||||
return $payload['message'];
|
||||
}
|
||||
|
||||
$message = trim($responseBody);
|
||||
|
||||
return $message === '' ? null : Str::limit($message, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function output(array $payload, string $fallback): string
|
||||
{
|
||||
$data = $payload['data'] ?? [];
|
||||
$output = is_array($data) && is_string($data['output'] ?? null) ? $data['output'] : '';
|
||||
|
||||
return $output !== '' ? $output : $fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Flux;
|
||||
|
||||
class FluxHealth
|
||||
{
|
||||
/**
|
||||
* @return array{available: bool, label: string, message: string, socket: string|null}
|
||||
*/
|
||||
public function check(): array
|
||||
{
|
||||
$socketPath = config('flux.unix_socket_path');
|
||||
|
||||
if (! is_string($socketPath) || $socketPath === '') {
|
||||
return $this->unavailable(null, 'Flux socket is not configured.');
|
||||
}
|
||||
|
||||
if (! file_exists($socketPath)) {
|
||||
return $this->unavailable($socketPath, 'Flux socket was not found.');
|
||||
}
|
||||
|
||||
$timeout = (float) config('flux.health_timeout_seconds', 1.0);
|
||||
$stream = @stream_socket_client("unix://{$socketPath}", $errorCode, $errorMessage, $timeout);
|
||||
|
||||
if ($stream === false) {
|
||||
return $this->unavailable($socketPath, $errorMessage ?: "Could not connect to Flux socket ({$errorCode}).");
|
||||
}
|
||||
|
||||
stream_set_timeout($stream, (int) ceil($timeout));
|
||||
|
||||
fwrite($stream, "GET /v1/health HTTP/1.1\r\nHost: flux\r\nAccept: application/json\r\nConnection: close\r\n\r\n");
|
||||
$response = stream_get_contents($stream) ?: '';
|
||||
fclose($stream);
|
||||
|
||||
if (! str_starts_with($response, 'HTTP/1.1 200') && ! str_starts_with($response, 'HTTP/1.0 200')) {
|
||||
return $this->unavailable($socketPath, 'Flux health endpoint did not return HTTP 200.');
|
||||
}
|
||||
|
||||
$body = str_contains($response, "\r\n\r\n") ? substr($response, strpos($response, "\r\n\r\n") + 4) : '';
|
||||
$payload = json_decode($body, true);
|
||||
|
||||
if (! is_array($payload) || ($payload['ok'] ?? false) !== true) {
|
||||
return $this->unavailable($socketPath, 'Flux health endpoint returned an invalid response.');
|
||||
}
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'label' => 'Running',
|
||||
'message' => 'Flux is running.',
|
||||
'socket' => $socketPath,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{available: false, label: string, message: string, socket: string|null}
|
||||
*/
|
||||
private function unavailable(?string $socketPath, string $message): array
|
||||
{
|
||||
return [
|
||||
'available' => false,
|
||||
'label' => 'Unavailable',
|
||||
'message' => $message,
|
||||
'socket' => $socketPath,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\V5;
|
||||
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
|
||||
/**
|
||||
* Single source of truth for the canvas resource payloads served by the
|
||||
* dashboard Inertia props and broadcast by V5CanvasResourceUpdated — the two
|
||||
* must stay identical for websocket vs. initial-load parity.
|
||||
*/
|
||||
class CanvasResourceSerializer
|
||||
{
|
||||
public const CARD_WIDTH = 320;
|
||||
|
||||
public const CARD_HEIGHT = 144;
|
||||
|
||||
public const CARD_GAP = 32;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function serializeApplication(V5Application $application): array
|
||||
{
|
||||
$application->loadMissing(['server', 'domains', 'project', 'environment']);
|
||||
$server = $application->server;
|
||||
$isServerReachable = ! $server instanceof V5Server || $this->isServerReachable($server);
|
||||
|
||||
return [
|
||||
'id' => $application->uuid,
|
||||
'name' => $application->name,
|
||||
'image' => $application->image,
|
||||
'containerName' => $application->container_name,
|
||||
'status' => $application->status,
|
||||
'statusMessage' => $application->status_message,
|
||||
'effectiveStatus' => $isServerReachable ? $application->status : 'unknown',
|
||||
'effectiveStatusMessage' => $isServerReachable
|
||||
? $application->status_message
|
||||
: $this->serverStatusMessage($server),
|
||||
'runtimeContainerId' => $application->runtime_container_id,
|
||||
'serverName' => $server?->name,
|
||||
'serverStatus' => $server?->status,
|
||||
'serverStatusMessage' => $server instanceof V5Server ? $this->serverStatusMessage($server) : null,
|
||||
'isServerReachable' => $isServerReachable,
|
||||
'serverIngressEnabled' => (bool) $server?->isIngress(),
|
||||
'meshNamespace' => $application->mesh_namespace,
|
||||
'ingressEnabled' => $application->ingress_enabled,
|
||||
'internalPort' => $application->internal_port,
|
||||
'domains' => $application->domains->pluck('domain')->values()->all(),
|
||||
'meshFqdn' => $application->container_name.'.'.($application->mesh_namespace ?: 'default').'.coolify.internal',
|
||||
'projectUuid' => $application->project?->uuid,
|
||||
'environmentUuid' => $application->environment?->uuid,
|
||||
'canvasX' => $application->canvas_x,
|
||||
'canvasY' => $application->canvas_y,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function serializeCaddyIngress(V5Server $server, int $index = 0): array
|
||||
{
|
||||
$isServerReachable = $this->isServerReachable($server);
|
||||
|
||||
return [
|
||||
'id' => $server->uuid,
|
||||
'name' => $server->name,
|
||||
'host' => $server->host,
|
||||
'type' => $server->ingressType(),
|
||||
'status' => $isServerReachable ? $server->ingressStatus() : 'unreachable',
|
||||
'statusMessage' => $isServerReachable ? null : $this->serverStatusMessage($server),
|
||||
'canvasX' => $server->canvas_x ?? -(self::CARD_WIDTH + self::CARD_GAP),
|
||||
'canvasY' => $server->canvas_y ?? $index * (self::CARD_HEIGHT + self::CARD_GAP),
|
||||
];
|
||||
}
|
||||
|
||||
private function isServerReachable(V5Server $server): bool
|
||||
{
|
||||
return $server->status !== 'unreachable';
|
||||
}
|
||||
|
||||
private function serverStatusMessage(?V5Server $server): ?string
|
||||
{
|
||||
return $server?->last_status_output ?: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\V5;
|
||||
|
||||
use App\Models\V5\Cluster as V5Cluster;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
|
||||
/**
|
||||
* Single source of truth for the cluster payload served by the Clusters
|
||||
* Inertia props and broadcast by V5ClusterUpdated — the two must stay
|
||||
* identical for websocket vs. initial-load parity.
|
||||
*/
|
||||
class ClusterSerializer
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function serialize(V5Cluster $cluster): array
|
||||
{
|
||||
return [
|
||||
'id' => $cluster->uuid,
|
||||
'name' => $cluster->name,
|
||||
'description' => $cluster->description,
|
||||
'wireguardInterface' => $cluster->wireguard_interface,
|
||||
'wireguardManagementPool' => $cluster->wireguard_management_pool,
|
||||
'wireguardListenPort' => $cluster->wireguard_listen_port,
|
||||
'containerNetworkPool' => $cluster->container_network_pool,
|
||||
'containerNetworkPrefix' => $cluster->container_network_prefix,
|
||||
'namespaces' => $cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES,
|
||||
'defaultDenyContainers' => $cluster->default_deny_containers,
|
||||
'cooldVersion' => $cluster->coold_version,
|
||||
'corrosionVersion' => $cluster->corrosion_version,
|
||||
'corrosionGossipPort' => $cluster->corrosion_gossip_port,
|
||||
'corrosionApiPort' => $cluster->corrosion_api_port,
|
||||
'builderEnabled' => $cluster->builder_enabled,
|
||||
'builderCapacity' => $cluster->builder_capacity,
|
||||
'builderCpuQuota' => $cluster->builder_cpu_quota,
|
||||
'builderMemoryMax' => $cluster->builder_memory_max,
|
||||
'builderTimeoutSecs' => $cluster->builder_timeout_secs,
|
||||
'lastCliAction' => $cluster->last_cli_action,
|
||||
'lastCliStatus' => $cluster->last_cli_status,
|
||||
'lastCliSummary' => $cluster->last_cli_summary,
|
||||
'lastCliRanAt' => $cluster->last_cli_ran_at?->toJSON(),
|
||||
'serversCount' => $cluster->servers_count ?? $cluster->servers->count(),
|
||||
'servers' => $cluster->servers->map(fn (V5Server $server) => [
|
||||
'id' => $server->uuid,
|
||||
'name' => $server->name,
|
||||
'host' => $server->host,
|
||||
'status' => $server->status,
|
||||
'capabilities' => $server->capabilities ?? [],
|
||||
'builderEnabled' => $server->builder_enabled,
|
||||
'builderCapacity' => $server->builder_capacity,
|
||||
'builderCpuQuota' => $server->builder_cpu_quota,
|
||||
'ingressEnabled' => $server->isIngress(),
|
||||
'ingressType' => $server->ingress_type,
|
||||
'uuid' => $server->uuid,
|
||||
'nodeAddress' => $server->node_address,
|
||||
'wireguardListenPortOverride' => $server->wireguard_listen_port_override,
|
||||
'wireguardEndpointOverride' => $server->wireguard_endpoint_override,
|
||||
'wireguardManagementIp' => $server->wireguard_management_ip,
|
||||
'wireguardPublicKey' => $server->wireguard_public_key,
|
||||
'containerSubnets' => $server->container_subnets ?? [],
|
||||
'privateKeyName' => $server->privateKey?->name,
|
||||
'lastBootstrappedAt' => $server->last_bootstrapped_at?->toJSON(),
|
||||
'lastBootstrapAction' => $server->last_bootstrap_action,
|
||||
'lastBootstrapStatus' => $server->last_bootstrap_status,
|
||||
'lastBootstrapOutput' => $server->last_bootstrap_output,
|
||||
'lastBootstrapRanAt' => $server->last_bootstrap_ran_at?->toJSON(),
|
||||
'lastStatusOutput' => $server->last_status_output,
|
||||
'lastStatusCheckedAt' => $server->last_status_checked_at?->toJSON(),
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload servers (with keys) and counts before serializing so the payload
|
||||
* always reflects the latest database state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function serializeFresh(V5Cluster $cluster): array
|
||||
{
|
||||
$cluster->load(['servers' => fn ($query) => $query
|
||||
->with('privateKey')
|
||||
->orderBy('name')]);
|
||||
$cluster->loadCount('servers');
|
||||
|
||||
return $this->serialize($cluster);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\V5;
|
||||
|
||||
use App\Exceptions\V5\UnsupportedCooldVerb;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Single source of truth for deriving node firewall rules from a resource
|
||||
* connection's DB rules and converging them through Flux. Reusable from
|
||||
* controllers, jobs, and events alike; deterministic rule ids keep repeated
|
||||
* syncs and compensating rollbacks idempotent.
|
||||
*/
|
||||
class ConnectionFirewallSync
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}>
|
||||
*/
|
||||
public function rulesFor(ResourceConnection $connection): Collection
|
||||
{
|
||||
$applicationIds = $connection->rules
|
||||
->flatMap(fn ($rule) => [$rule->source_resource_id, $rule->target_resource_id])
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$applications = V5Application::query()
|
||||
->whereIn('id', $applicationIds)
|
||||
->with('server')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return $connection->rules
|
||||
->flatMap(function ($rule) use ($applications, $connection): Collection {
|
||||
$source = $applications->get($rule->source_resource_id);
|
||||
$target = $applications->get($rule->target_resource_id);
|
||||
|
||||
if (! $source instanceof V5Application || ! $target instanceof V5Application) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$missingHost = collect([$source, $target])
|
||||
->first(function (V5Application $application): bool {
|
||||
$hostId = $application->server?->fluxHostId();
|
||||
|
||||
return ! is_string($hostId) || $hostId === '';
|
||||
});
|
||||
|
||||
if ($missingHost instanceof V5Application) {
|
||||
throw new \RuntimeException("Application {$missingHost->name} has no reachable server host id, so its firewall rules cannot be synced.");
|
||||
}
|
||||
|
||||
$hostIds = collect([$source->server, $target->server])
|
||||
->map(fn (V5Server $server) => $server->fluxHostId())
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$firewallRule = [
|
||||
'id' => $this->ruleId($connection, $rule),
|
||||
'namespace' => $target->mesh_namespace ?: 'default',
|
||||
'src' => $source->container_name,
|
||||
'dst' => $target->container_name,
|
||||
'proto' => $rule->protocol ?: 'tcp',
|
||||
'port' => (int) $rule->port,
|
||||
];
|
||||
|
||||
return $hostIds->map(fn (string $hostId): array => [
|
||||
'id' => $firewallRule['id'],
|
||||
'hostId' => $hostId,
|
||||
'rule' => $firewallRule,
|
||||
]);
|
||||
})
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}> $oldRules
|
||||
* @param Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}> $newRules
|
||||
*/
|
||||
public function sync(FluxClient $fluxClient, Collection $oldRules, Collection $newRules): void
|
||||
{
|
||||
$newRuleKeys = $newRules->map(fn (array $rule): string => $this->syncKey($rule))->all();
|
||||
$oldRuleKeys = $oldRules->map(fn (array $rule): string => $this->syncKey($rule))->all();
|
||||
|
||||
$oldRules
|
||||
->reject(fn (array $oldRule): bool => in_array($this->syncKey($oldRule), $newRuleKeys, true))
|
||||
->each(fn (array $oldRule): ?string => $this->revokeRuleIfPresent($fluxClient, $oldRule['hostId'], $oldRule['id']));
|
||||
|
||||
$newRules
|
||||
->reject(fn (array $newRule): bool => in_array($this->syncKey($newRule), $oldRuleKeys, true))
|
||||
->each(function (array $newRule) use ($fluxClient): void {
|
||||
try {
|
||||
$fluxClient->applyFirewallRule($newRule['hostId'], $newRule['rule']);
|
||||
} catch (UnsupportedCooldVerb $exception) {
|
||||
Log::warning('V5 resource connection firewall rule skipped: coold verb unsupported', [
|
||||
'host_id' => $newRule['hostId'],
|
||||
'rule_id' => $newRule['id'],
|
||||
'verb' => $exception->verb,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function revokeRuleIfPresent(FluxClient $fluxClient, string $hostId, string $ruleId): ?string
|
||||
{
|
||||
try {
|
||||
return $fluxClient->revokeFirewallRule($hostId, $ruleId);
|
||||
} catch (UnsupportedCooldVerb $exception) {
|
||||
Log::warning('V5 resource connection firewall revoke skipped: coold verb unsupported', [
|
||||
'host_id' => $hostId,
|
||||
'rule_id' => $ruleId,
|
||||
'verb' => $exception->verb,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
} catch (\RuntimeException $exception) {
|
||||
if (str_contains(Str::lower($exception->getMessage()), 'not found')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic node-side rule id derived only from the connection id and
|
||||
* the rule's stable attributes — never from the rule row's primary key —
|
||||
* so rewritten or restored DB rows resolve to the same firewall rule ids
|
||||
* and compensating re-syncs stay idempotent.
|
||||
*/
|
||||
public function ruleId(ResourceConnection $connection, mixed $rule): string
|
||||
{
|
||||
return implode(':', [
|
||||
'v5-resource-connection',
|
||||
$connection->id,
|
||||
$rule->source_resource_id,
|
||||
$rule->target_resource_id,
|
||||
$rule->protocol ?: 'tcp',
|
||||
(int) $rule->port,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}} $rule
|
||||
*/
|
||||
private function syncKey(array $rule): string
|
||||
{
|
||||
return $rule['hostId'].'|'.$rule['id'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\V5;
|
||||
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Single source of truth for the resource connection payloads served by the
|
||||
* dashboard Inertia props and the connection endpoints — the wire format is
|
||||
* consumed by resources/js/v5/types.ts and must stay stable.
|
||||
*/
|
||||
class ResourceConnectionSerializer
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function serialize(ResourceConnection $connection): array
|
||||
{
|
||||
$applications = $this->applicationsById($connection);
|
||||
$resourceOneUuid = $applications->get($connection->resource_one_id)?->uuid;
|
||||
$resourceTwoUuid = $applications->get($connection->resource_two_id)?->uuid;
|
||||
$applicationsById = $applications;
|
||||
|
||||
return [
|
||||
'id' => $connection->uuid,
|
||||
'applicationIds' => array_values(array_filter([
|
||||
$resourceOneUuid,
|
||||
$resourceTwoUuid,
|
||||
])),
|
||||
'fromApplicationId' => $resourceOneUuid,
|
||||
'toApplicationId' => $resourceTwoUuid,
|
||||
'portsByDirection' => $connection->rules
|
||||
->groupBy(function ($rule) use ($applicationsById): string {
|
||||
$sourceUuid = $applicationsById->get($rule->source_resource_id)?->uuid;
|
||||
$targetUuid = $applicationsById->get($rule->target_resource_id)?->uuid;
|
||||
|
||||
return "{$sourceUuid}->{$targetUuid}";
|
||||
})
|
||||
->filter(fn (Collection $rules, string $direction): bool => ! str_starts_with($direction, '->') && ! str_ends_with($direction, '->'))
|
||||
->map(fn (Collection $rules) => $rules
|
||||
->sortBy('port')
|
||||
->pluck('port')
|
||||
->map(fn ($port) => (string) $port)
|
||||
->values()
|
||||
->all())
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<string, V5Application>
|
||||
*/
|
||||
public function applicationsByUuid(ResourceConnection $connection): Collection
|
||||
{
|
||||
return $this->applicationsById($connection)->keyBy('uuid');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, V5Application>
|
||||
*/
|
||||
public function applicationsById(ResourceConnection $connection): Collection
|
||||
{
|
||||
return V5Application::query()
|
||||
->whereIn('id', [
|
||||
(int) $connection->resource_one_id,
|
||||
(int) $connection->resource_two_id,
|
||||
])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\V5;
|
||||
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Enums\V5\ContainerState;
|
||||
use App\Enums\V5\IngressStatus;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Shared status-observation watermarking and enum normalization used by every
|
||||
* v5 status write path (the flux webhook, the reconcile job, and the manual
|
||||
* refresh endpoint) so out-of-order updates are dropped and raw coold states
|
||||
* are normalized identically everywhere.
|
||||
*/
|
||||
class StatusObservation
|
||||
{
|
||||
/**
|
||||
* A write whose observation timestamp is older than the one already
|
||||
* persisted is stale (delivered or computed out of order) and must not
|
||||
* clobber the newer state.
|
||||
*
|
||||
* @param array<string, mixed> $logContext
|
||||
*/
|
||||
public static function isStale(?CarbonInterface $observedAt, ?CarbonInterface $currentObservedAt, string $context, array $logContext): bool
|
||||
{
|
||||
if ($observedAt === null || $currentObservedAt === null || ! $observedAt->lt($currentObservedAt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Log::debug("Dropping stale flux {$context} update.", [
|
||||
...$logContext,
|
||||
'observed_at' => $observedAt->toIso8601String(),
|
||||
'current_status_observed_at' => $currentObservedAt->toIso8601String(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw status string onto the given status enum. Unknown values are
|
||||
* never written to the database: they fall back to the enum's Unknown case
|
||||
* and are logged. Returns null only when no raw value is supplied.
|
||||
*
|
||||
* @param class-string<ApplicationStatus|ContainerState|IngressStatus|ServerStatus> $enumClass
|
||||
*/
|
||||
public static function normalize(?string $raw, string $enumClass): ?string
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$status = $enumClass::tryFrom(strtolower($raw));
|
||||
|
||||
if ($status === null) {
|
||||
Log::warning('Received unknown flux resource status; falling back to unknown.', [
|
||||
'raw_status' => $raw,
|
||||
'status_enum' => $enumClass,
|
||||
]);
|
||||
|
||||
return $enumClass::Unknown->value;
|
||||
}
|
||||
|
||||
return $status->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\V5;
|
||||
|
||||
class V5Feature
|
||||
{
|
||||
private const DEVELOPMENT_ENVIRONMENTS = ['local', 'development', 'dev', 'testing'];
|
||||
|
||||
public static function enabled(): bool
|
||||
{
|
||||
return (bool) config('v5.enabled');
|
||||
}
|
||||
|
||||
public static function enabledForEnvironment(string $environment): bool
|
||||
{
|
||||
return in_array($environment, self::DEVELOPMENT_ENVIRONMENTS, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-lyra",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "resources/css/v5/app.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "phosphor",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'coolify_cli_bin' => env('COOLIFY_CLI_BIN', '/usr/local/bin/coolify'),
|
||||
'coold_version' => env('COOLIFY_COOLD_VERSION', 'nightly'),
|
||||
'corrosion_version' => env('COOLIFY_CORROSION_VERSION', 'v1.0.0'),
|
||||
'dev_ssh_user' => env('COOLIFY_CLI_SSH_USER', 'coolify'),
|
||||
'flux_url' => env('COOLIFY_COOLD_FLUX_URL', env('COOLIFY_COOLD_VM_FLUX_URL')),
|
||||
'flux_host_jwt_path' => env('COOLIFY_COOLD_HOST_JWT_PATH', '/etc/coolify/host-jwt'),
|
||||
];
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'unix_socket_path' => env('COOLIFY_FLUX_UNIX_SOCKET_PATH', '/run/coolify/flux.sock'),
|
||||
'jwt_private_key_path' => env('COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH', storage_path('app/flux/jwt.priv')),
|
||||
'jwt_public_key_path' => env('COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH', storage_path('app/flux/jwt.pub')),
|
||||
'health_timeout_seconds' => (float) env('COOLIFY_FLUX_HEALTH_TIMEOUT_SECONDS', 1.0),
|
||||
'connection_timeout_seconds' => (float) env('COOLIFY_FLUX_CONNECTION_TIMEOUT_SECONDS', 1.0),
|
||||
'dispatch_timeout_seconds' => (float) env('COOLIFY_FLUX_DISPATCH_TIMEOUT_SECONDS', 35.0),
|
||||
'bootstrap_host_connection_timeout_seconds' => (int) env('COOLIFY_FLUX_BOOTSTRAP_HOST_CONNECTION_TIMEOUT_SECONDS', 30),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Host agent (coold) token capabilities
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The EXACT set of primitive capability strings coold advertises to flux on
|
||||
| connect (coold/coold/src/grpc/client.rs, `primitive_capabilities`). Minting
|
||||
| these explicit strings — instead of the `host-agent:default` wildcard
|
||||
| profile — means the token no longer relies on flux's
|
||||
| `capability_profile_authorizes_all` bypass. flux INTERSECTS the jwt `caps`
|
||||
| with coold's advertised set, so as long as this list matches coold's
|
||||
| advertised primitives the host retains exactly the same effective power.
|
||||
|
|
||||
| SAFETY: keep this list byte-for-byte in sync with coold's
|
||||
| `primitive_capabilities()`. A string here that coold does not advertise is
|
||||
| silently dropped by flux's intersection; a verb coold needs that is missing
|
||||
| here means the host loses that ability.
|
||||
|
|
||||
| `host.jwt.set` authorizes RPC-delivered host-JWT rotation (the token can
|
||||
| authorize its own replacement over the live stream; Laravel is the root of
|
||||
| trust that holds the signing key).
|
||||
*/
|
||||
'host_capabilities' => [
|
||||
'images.pull',
|
||||
'images.list',
|
||||
'images.delete',
|
||||
'containers.create',
|
||||
'containers.start',
|
||||
'containers.stop',
|
||||
'containers.restart',
|
||||
'containers.delete',
|
||||
'containers.inspect',
|
||||
'containers.list',
|
||||
'containers.logs',
|
||||
'containers.exec',
|
||||
'containers.healthcheck.run',
|
||||
'ingress.apply',
|
||||
'ingress.stop',
|
||||
'firewall.allow',
|
||||
'firewall.revoke',
|
||||
'firewall.list',
|
||||
'firewall.reconcile',
|
||||
'coold.logs',
|
||||
'corrosion.tables',
|
||||
'host.jwt.set',
|
||||
],
|
||||
|
||||
/*
|
||||
| Emergency escape hatch: when set (e.g. to `host-agent:default`), minted
|
||||
| host tokens carry ONLY this single capability profile instead of the
|
||||
| explicit list above. This re-enables flux's wildcard bypass and is meant
|
||||
| purely for rollback without a code change if the explicit list ever drifts
|
||||
| from coold's advertised set and breaks the data plane. Leave NULL in
|
||||
| production so tokens are explicitly scoped.
|
||||
*/
|
||||
'host_capability_profile' => env('COOLIFY_FLUX_HOST_CAPABILITY_PROFILE'),
|
||||
|
||||
/*
|
||||
| Host JWT lifetime and rotation.
|
||||
|
|
||||
| `host_token_ttl` is the token `exp` window (default 1h) — the maximum time
|
||||
| a leaked/undetected token stays valid if BOTH rotation and revocation fail.
|
||||
| Keep this at or below flux's `COOLIFY_FLUX_MAX_TOKEN_LIFETIME_SECS`
|
||||
| default (3600), otherwise flux rejects coold streams at connect.
|
||||
| `host_token_refresh_threshold` is the remaining-lifetime below which the
|
||||
| rotation job re-mints and re-delivers a fresh token (default 30m).
|
||||
| Keep the threshold below the TTL.
|
||||
*/
|
||||
'host_token_ttl' => (int) env('COOLIFY_FLUX_HOST_TOKEN_TTL', 3600),
|
||||
'host_token_refresh_threshold' => (int) env('COOLIFY_FLUX_HOST_TOKEN_REFRESH_THRESHOLD', 1800),
|
||||
|
||||
/*
|
||||
| JWT header `kid` minted into host tokens. flux selects the verification key
|
||||
| by this id (single default key today; a per-cluster keys directory can map
|
||||
| `kid = cluster-<id>` to `<kid>.pub` for per-tenant signing keys later).
|
||||
*/
|
||||
'jwt_kid' => env('COOLIFY_FLUX_JWT_KID', 'flux-default'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Inbound flux -> Laravel API token(s)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| flux authenticates to Laravel's internal status-ingest endpoint with a
|
||||
| bearer token. `laravel_api_tokens` accepts SEVERAL tokens at once so an
|
||||
| operator can rotate with zero downtime:
|
||||
| 1. add the new token alongside the old:
|
||||
| COOLIFY_FLUX_LARAVEL_API_TOKENS=<old>,<new>
|
||||
| then `php artisan config:clear`
|
||||
| 2. cut every flux instance over to <new>
|
||||
| 3. drop <old> from the list and `config:clear` again
|
||||
| Generate tokens with `openssl rand -hex 32`. The single `laravel_api_token`
|
||||
| remains as a fallback so existing single-token installs keep working.
|
||||
*/
|
||||
'laravel_api_token' => env('COOLIFY_FLUX_LARAVEL_API_TOKEN'),
|
||||
'laravel_api_tokens' => array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) env('COOLIFY_FLUX_LARAVEL_API_TOKENS', ''))
|
||||
))),
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Support\V5\V5Feature;
|
||||
|
||||
return [
|
||||
'enabled' => V5Feature::enabledForEnvironment((string) env('APP_ENV', 'production')),
|
||||
];
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Actions\V5\Server\SyncDevLimaServers;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class V5DevLimaSeeder extends Seeder
|
||||
{
|
||||
private const CLUSTER_NAME = 'Development-Lima';
|
||||
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$team = Team::query()->find(0) ?? Team::query()->orderBy('id')->first();
|
||||
$user = User::query()->find(0) ?? User::query()->orderBy('id')->first();
|
||||
|
||||
if (! $team instanceof Team || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$privateKey = PrivateKey::query()
|
||||
->where('team_id', $team->id)
|
||||
->where('is_git_related', false)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
$sshUser = (string) config('coold.dev_ssh_user', 'coolify');
|
||||
$servers = collect($this->servers())
|
||||
->map(fn (array $server): array => [
|
||||
...$server,
|
||||
'ssh_user' => $sshUser,
|
||||
])
|
||||
->all();
|
||||
|
||||
SyncDevLimaServers::run(
|
||||
team: $team,
|
||||
user: $user,
|
||||
privateKey: $privateKey,
|
||||
clusterName: self::CLUSTER_NAME,
|
||||
servers: $servers,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name: string, host: string, ssh_port: int, wireguard_management_ip: string, wireguard_listen_port_override: int, wireguard_endpoint_override: string}>
|
||||
*/
|
||||
private function servers(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'name' => 'coold-dev',
|
||||
'host' => 'coold-dev.local',
|
||||
'ssh_port' => 22,
|
||||
'wireguard_management_ip' => '100.64.0.1',
|
||||
'wireguard_listen_port_override' => 51821,
|
||||
'wireguard_endpoint_override' => 'coold-dev.local:51821',
|
||||
],
|
||||
[
|
||||
'name' => 'coold-dev-2',
|
||||
'host' => 'coold-dev-2.local',
|
||||
'ssh_port' => 22,
|
||||
'wireguard_management_ip' => '100.64.0.2',
|
||||
'wireguard_listen_port_override' => 51822,
|
||||
'wireguard_endpoint_override' => 'coold-dev-2.local:51822',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
# coold Dev Environment Notes
|
||||
|
||||
This file documents the current local v5/coold dev setup in Coolify.
|
||||
|
||||
## Roles
|
||||
|
||||
- `scripts/dev.sh` is the main developer-facing entrypoint. Use it for normal
|
||||
local workflows such as starting/stopping the stack, creating fresh dev state,
|
||||
inspecting Corrosion, managing firewall allow rules, and running example
|
||||
containers.
|
||||
- `scripts/coold-vm.sh` is a lower-level Lima VM helper used by `scripts/dev.sh`.
|
||||
It exists separately to keep VM lifecycle and guest setup details out of the
|
||||
main dev orchestration script. Call it directly only when debugging or
|
||||
operating an individual VM, for example `shell`, `status`, `logs-agent`, or
|
||||
`delete`.
|
||||
- Lima VMs act like real deployment servers.
|
||||
- `coolify init bootstrap` owns host wiring:
|
||||
- WireGuard
|
||||
- Podman mesh networks
|
||||
- Corrosion config/schema/service
|
||||
- coold install/service
|
||||
- builder install/config
|
||||
- default-deny firewall service
|
||||
- Coolify/Laravel dev owns Flux-specific wiring:
|
||||
- starts Flux inside the Coolify container
|
||||
- mints dev host JWTs
|
||||
- installs `/etc/coolify/host-jwt` into each VM
|
||||
- adds a `coold.service` systemd drop-in with `COOLIFY_COOLD_FLUX_URL`
|
||||
|
||||
## Main commands
|
||||
|
||||
```bash
|
||||
scripts/dev.sh up
|
||||
scripts/dev.sh down
|
||||
scripts/dev.sh clean-vms
|
||||
scripts/dev.sh list
|
||||
```
|
||||
|
||||
`clean-vms` deletes the Lima VM instances and VM-local state, including disks,
|
||||
containers, WireGuard keys, Corrosion DB, installed binaries, and firewall state.
|
||||
It does not delete the Coolify repo.
|
||||
|
||||
## coolify helper
|
||||
|
||||
```bash
|
||||
scripts/dev.sh coolify install
|
||||
scripts/dev.sh coolify path
|
||||
scripts/dev.sh coolify bootstrap-command
|
||||
scripts/dev.sh coolify run <args>
|
||||
```
|
||||
|
||||
The helper runs the `coolify` CLI from the Coolify development container. Lima
|
||||
VMs are addressed by their bridged mDNS names (`<vm>.local`), so the same
|
||||
addresses are used by bootstrap and by the v5 dev server seeder.
|
||||
|
||||
Because Docker Desktop does not reliably pass mDNS multicast into containers,
|
||||
`scripts/dev.sh up` resolves each `<vm>.local` name on the host and writes the
|
||||
resolved records into the Coolify container `/etc/hosts` before bootstrapping.
|
||||
|
||||
The generated bootstrap command uses the container CLI, the repo-local copy of
|
||||
the Lima SSH key, and dev WireGuard endpoint overrides, for example:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml exec -T coolify /usr/local/bin/coolify init bootstrap \
|
||||
--nodes "coold-dev.local,coold-dev-2.local" \
|
||||
--ssh-key "/var/www/html/.dev/lima/ssh_key" \
|
||||
--ssh-user "coolify" \
|
||||
--wg-listen-port-overrides "coold-dev.local=51821,coold-dev-2.local=51822" \
|
||||
--wg-endpoint-overrides "coold-dev.local=coold-dev.local:51821,coold-dev-2.local=coold-dev-2.local:51822" \
|
||||
--coold-version "nightly" \
|
||||
--corrosion-version "v1.0.0" \
|
||||
--yes
|
||||
```
|
||||
|
||||
Lima does not allow direct root SSH by default, so dev uses the normal Lima user
|
||||
with passwordless sudo. `coolify` wraps remote commands in `sudo -n bash -lc`
|
||||
when the SSH user is not `root`.
|
||||
|
||||
## Default dev topology
|
||||
|
||||
After `coolify init bootstrap`, defaults are:
|
||||
|
||||
| VM | WireGuard IP | WireGuard endpoint | Podman subnet | Gateway |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `coold-dev` | `100.64.0.1` | `coold-dev.local:51821` | `10.210.0.0/24` | `10.210.0.1` |
|
||||
| `coold-dev-2` | `100.64.0.2` | `coold-dev-2.local:51822` | `10.210.1.0/24` | `10.210.1.1` |
|
||||
|
||||
## Checking state
|
||||
|
||||
```bash
|
||||
scripts/dev.sh corrosion check
|
||||
scripts/dev.sh corrosion containers
|
||||
scripts/dev.sh corrosion config
|
||||
scripts/dev.sh corrosion logs 1
|
||||
scripts/dev.sh corrosion logs 2
|
||||
```
|
||||
|
||||
`corrosion containers` shows both Corrosion `service_endpoints` and rootful /
|
||||
rootless Podman containers.
|
||||
|
||||
## Example nginx containers
|
||||
|
||||
```bash
|
||||
scripts/dev.sh example-nginx up
|
||||
scripts/dev.sh example-nginx check-dns
|
||||
scripts/dev.sh example-nginx down
|
||||
```
|
||||
|
||||
The example containers are started with coold DNS:
|
||||
|
||||
```bash
|
||||
--dns <local-mesh-gateway>
|
||||
--dns-search default.coolify.internal
|
||||
```
|
||||
|
||||
Expected service discovery format:
|
||||
|
||||
```text
|
||||
<container-name>.default.coolify.internal
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
coolify-example-nginx-2.default.coolify.internal -> 10.210.1.x
|
||||
```
|
||||
|
||||
## Firewall behavior
|
||||
|
||||
The mesh firewall is default-deny for inter-container traffic. Host-to-host
|
||||
WireGuard traffic can work while container-to-container traffic is blocked.
|
||||
|
||||
Allow traffic:
|
||||
|
||||
```bash
|
||||
scripts/dev.sh firewall allow 10.210.0.2 10.210.1.2 tcp 80
|
||||
scripts/dev.sh firewall list
|
||||
```
|
||||
|
||||
Revoke traffic:
|
||||
|
||||
```bash
|
||||
scripts/dev.sh firewall revoke
|
||||
scripts/dev.sh firewall revoke <rule-id>
|
||||
```
|
||||
|
||||
### Why dev adds allow rules to both hosts
|
||||
|
||||
`business traffic` from container A on host 1 to container B on host 2 crosses
|
||||
forwarding/firewall logic on both sides:
|
||||
|
||||
```text
|
||||
10.210.0.2
|
||||
-> source host bridge/firewall
|
||||
-> coold-dev wg0
|
||||
-> WireGuard
|
||||
-> coold-dev-2 wg0
|
||||
-> destination host bridge/firewall
|
||||
-> 10.210.1.2
|
||||
```
|
||||
|
||||
The default-deny hooks can drop the packet on either the source or destination
|
||||
host. For the two-node dev setup, `scripts/dev.sh firewall allow` writes the
|
||||
same allow tuple to every coold VM so the path works reliably.
|
||||
|
||||
Production should become topology-aware instead of blindly writing to every
|
||||
host:
|
||||
|
||||
- cross-host traffic: write allow rules to the source and destination hosts
|
||||
- same-host traffic: write the allow rule only to that host
|
||||
- larger clusters: do not install unrelated allow rules on unaffected hosts
|
||||
|
||||
## Manual connectivity checks
|
||||
|
||||
Before allow rule, this should time out:
|
||||
|
||||
```bash
|
||||
scripts/dev.sh shell 1
|
||||
sudo podman exec coolify-example-nginx wget -T 3 -qO- http://10.210.1.2
|
||||
```
|
||||
|
||||
After allow rule, this should return nginx HTML:
|
||||
|
||||
```bash
|
||||
scripts/dev.sh firewall allow 10.210.0.2 10.210.1.2 tcp 80
|
||||
scripts/dev.sh shell 1
|
||||
sudo podman exec coolify-example-nginx wget -T 5 -qO- http://10.210.1.2 | head -n 1
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
vmType: "vz"
|
||||
arch: "default"
|
||||
user:
|
||||
name: coolify
|
||||
cpus: 4
|
||||
memory: "2GiB"
|
||||
disk: "20GiB"
|
||||
containerd:
|
||||
system: false
|
||||
user: false
|
||||
|
||||
images:
|
||||
- location: "https://cloud-images.ubuntu.com/releases/26.04/release/ubuntu-26.04-server-cloudimg-amd64.img"
|
||||
arch: "x86_64"
|
||||
- location: "https://cloud-images.ubuntu.com/releases/26.04/release/ubuntu-26.04-server-cloudimg-arm64.img"
|
||||
arch: "aarch64"
|
||||
|
||||
|
||||
networks:
|
||||
- lima: bridged
|
||||
|
||||
provision:
|
||||
- mode: system
|
||||
script: |
|
||||
coolify_test_public_key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68 sail@76ff66d2e2dd"
|
||||
|
||||
|
||||
echo "[coold-vm] Installing and configuring mDNS."
|
||||
apt-get update
|
||||
apt-get install -y avahi-daemon
|
||||
hostnamectl set-hostname "{{.Name}}"
|
||||
systemctl enable --now avahi-daemon
|
||||
echo "[coold-vm] Installing SSH keys."
|
||||
|
||||
install_public_key() {
|
||||
user="$1"
|
||||
home="$(getent passwd "$user" | cut -d: -f6 || true)"
|
||||
if [ -z "$home" ] || [ ! -d "$home" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
owner="$(stat -c '%u:%g' "$home")"
|
||||
authorized_keys="$home/.ssh/authorized_keys"
|
||||
install -d -m 700 -o "${owner%:*}" -g "${owner#*:}" "$home/.ssh"
|
||||
touch "$authorized_keys"
|
||||
chown "$owner" "$authorized_keys"
|
||||
chmod 600 "$authorized_keys"
|
||||
grep -qxF "$coolify_test_public_key" "$authorized_keys" || echo "$coolify_test_public_key" >>"$authorized_keys"
|
||||
chown "$owner" "$authorized_keys"
|
||||
}
|
||||
|
||||
install_public_key root
|
||||
install_public_key coolify
|
||||
Whitespace-only changes.
@@ -0,0 +1,48 @@
|
||||
#!/bin/sh
|
||||
|
||||
cd /var/www/html
|
||||
|
||||
. /etc/s6-overlay/scripts/container-role
|
||||
role="$(coolify_container_role_value)"
|
||||
|
||||
if ! coolify_container_has_role flux; then
|
||||
echo " INFO Flux is disabled for role '$role', sleeping."
|
||||
exec sleep infinity
|
||||
fi
|
||||
|
||||
if grep -qE '^COOLIFY_FLUX_ENABLED=false' .env 2>/dev/null || [ "${COOLIFY_FLUX_ENABLED:-}" = "false" ]; then
|
||||
echo " INFO Flux is disabled, sleeping."
|
||||
exec sleep infinity
|
||||
fi
|
||||
|
||||
export COOLIFY_FLUX_GRPC_BIND="${COOLIFY_FLUX_GRPC_BIND:-0.0.0.0:6443}"
|
||||
export COOLIFY_FLUX_UNIX_SOCKET_PATH="${COOLIFY_FLUX_UNIX_SOCKET_PATH:-/run/coolify/flux.sock}"
|
||||
export COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH="${COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH:-/var/www/html/storage/app/flux/jwt.priv}"
|
||||
export COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH="${COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH:-/var/www/html/storage/app/flux/jwt.pub}"
|
||||
export COOLIFY_FLUX_ALLOW_PUBLIC_BIND="${COOLIFY_FLUX_ALLOW_PUBLIC_BIND:-1}"
|
||||
export COOLIFY_FLUX_REQUIRE_HOST_BINDING="${COOLIFY_FLUX_REQUIRE_HOST_BINDING:-0}"
|
||||
export COOLIFY_FLUX_LARAVEL_API_URL="${COOLIFY_FLUX_LARAVEL_API_URL:-http://127.0.0.1:8080}"
|
||||
if [ -z "${COOLIFY_FLUX_LARAVEL_API_TOKEN:-}" ] && [ -f .env ]; then
|
||||
COOLIFY_FLUX_LARAVEL_API_TOKEN="$(grep -E '^COOLIFY_FLUX_LARAVEL_API_TOKEN=' .env 2>/dev/null | tail -n1 | cut -d= -f2- | sed "s/^['\"]//; s/['\"]$//")"
|
||||
fi
|
||||
export COOLIFY_FLUX_LARAVEL_API_TOKEN="${COOLIFY_FLUX_LARAVEL_API_TOKEN:-}"
|
||||
if [ ! -r "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH" ]; then
|
||||
echo " INFO Flux JWT public key not found at $COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH, generating keypair..."
|
||||
mkdir -p "$(dirname "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH")" "$(dirname "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH")"
|
||||
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH.tmp"
|
||||
chmod 0600 "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH.tmp"
|
||||
mv "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH.tmp" "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH"
|
||||
openssl pkey -in "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH" -pubout -out "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH.tmp"
|
||||
chmod 0644 "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH.tmp"
|
||||
mv "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH.tmp" "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH"
|
||||
fi
|
||||
|
||||
if ! /usr/local/bin/flux --version >/dev/null 2>&1; then
|
||||
echo " ERROR Flux binary cannot run in this container. Check that the installed coold nightly Flux artifact is compatible with Alpine."
|
||||
exec sleep infinity
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$COOLIFY_FLUX_UNIX_SOCKET_PATH")"
|
||||
|
||||
echo " INFO Flux is enabled for role '$role', starting..."
|
||||
exec /usr/local/bin/flux
|
||||
@@ -0,0 +1 @@
|
||||
longrun
|
||||
Whitespace-only changes.
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\V5\ApplicationController;
|
||||
use App\Http\Controllers\V5\ClusterController;
|
||||
use App\Http\Controllers\V5\DashboardController;
|
||||
use App\Http\Controllers\V5\ResourceConnectionController;
|
||||
use App\Http\Controllers\V5\ServerController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware('v5.authenticated')->group(function () {
|
||||
Route::get('/', DashboardController::class)->name('dashboard');
|
||||
Route::get('/realtime-test', [DashboardController::class, 'realtimeTest'])->name('realtime-test');
|
||||
Route::post('/realtime-test', [DashboardController::class, 'broadcastRealtimeTest'])->name('realtime-test.broadcast');
|
||||
Route::post('/selection', [DashboardController::class, 'updateSelection'])->name('selection.update');
|
||||
Route::post('/applications/nginx', [ApplicationController::class, 'store'])->name('applications.nginx');
|
||||
Route::post('/applications/refresh', [ApplicationController::class, 'refresh'])->name('applications.refresh');
|
||||
Route::delete('/applications/{application}', [ApplicationController::class, 'destroy'])->name('applications.destroy');
|
||||
Route::patch('/applications/{application}/position', [ApplicationController::class, 'updatePosition'])->name('applications.position');
|
||||
Route::patch('/applications/{application}/ingress', [ApplicationController::class, 'updateIngress'])->name('applications.ingress');
|
||||
Route::get('/applications/{application}/logs', [ApplicationController::class, 'logs'])->name('applications.logs');
|
||||
Route::patch('/caddy-ingresses/{server}/position', [ApplicationController::class, 'updateCaddyIngressPosition'])->name('caddy-ingresses.position');
|
||||
Route::post('/resource-connections', [ResourceConnectionController::class, 'store'])->name('resource-connections.store');
|
||||
Route::patch('/resource-connections/{connection}', [ResourceConnectionController::class, 'update'])->name('resource-connections.update');
|
||||
Route::delete('/resource-connections/{connection}', [ResourceConnectionController::class, 'destroy'])->name('resource-connections.destroy');
|
||||
Route::get('/clusters', [ClusterController::class, 'index'])->name('clusters.index');
|
||||
Route::get('/clusters/{cluster}', [ClusterController::class, 'show'])->name('clusters.show');
|
||||
Route::post('/clusters', [ClusterController::class, 'store'])->name('clusters.store');
|
||||
Route::delete('/clusters/{cluster}', [ClusterController::class, 'destroy'])->name('clusters.destroy');
|
||||
Route::post('/clusters/{cluster}/servers', [ServerController::class, 'store'])->name('clusters.servers.store');
|
||||
Route::patch('/clusters/{cluster}/servers/{server}', [ServerController::class, 'update'])->name('clusters.servers.update');
|
||||
Route::post('/clusters/{cluster}/servers/{server}/check', [ServerController::class, 'check'])->name('clusters.servers.check');
|
||||
Route::post('/clusters/{cluster}/servers/{server}/restart-coold', [ServerController::class, 'restartCoold'])->name('clusters.servers.restart-coold');
|
||||
Route::get('/clusters/{cluster}/servers/{server}/coold-logs', [ServerController::class, 'cooldLogs'])->name('clusters.servers.coold-logs');
|
||||
Route::get('/clusters/{cluster}/servers/{server}/corrosion-tables', [ServerController::class, 'corrosionTables'])->name('clusters.servers.corrosion-tables');
|
||||
Route::get('/clusters/{cluster}/servers/{server}/firewall-rules', [ServerController::class, 'firewallRules'])->name('clusters.servers.firewall-rules');
|
||||
Route::post('/clusters/{cluster}/servers/{server}/bootstrap', [ServerController::class, 'bootstrap'])->name('clusters.servers.bootstrap');
|
||||
Route::delete('/clusters/{cluster}/servers/{server}', [ServerController::class, 'destroy'])->name('clusters.servers.destroy');
|
||||
});
|
||||
@@ -0,0 +1,672 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
read_coolify_env() {
|
||||
key="$1"
|
||||
default_value="$2"
|
||||
current_value="${!key:-}"
|
||||
|
||||
if [ -n "$current_value" ]; then
|
||||
printf '%s\n' "$current_value"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -f "$ROOT/.env" ]; then
|
||||
env_value="$(grep -E "^${key}=" "$ROOT/.env" 2>/dev/null | tail -n1 | cut -d= -f2- | sed "s/^['\"]//; s/['\"]$//")"
|
||||
if [ -n "$env_value" ]; then
|
||||
printf '%s\n' "$env_value"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '%s\n' "$default_value"
|
||||
}
|
||||
|
||||
INSTANCE="$(read_coolify_env COOLIFY_COOLD_LIMA_INSTANCE coold-dev)"
|
||||
VERSION="$(read_coolify_env COOLIFY_COOLD_VERSION nightly)"
|
||||
CORROSION_VERSION="$(read_coolify_env COOLIFY_CORROSION_VERSION v1.0.0)"
|
||||
FLUX_URL="$(read_coolify_env COOLIFY_COOLD_VM_FLUX_URL http://host.lima.internal:6443)"
|
||||
START_TIMEOUT="$(read_coolify_env COOLIFY_COOLD_VM_START_TIMEOUT 300)"
|
||||
WG_IP="$(read_coolify_env COOLIFY_COOLD_VM_WG_IP "")"
|
||||
WG_PEER_IP="$(read_coolify_env COOLIFY_COOLD_VM_WG_PEER_IP "")"
|
||||
WG_PEER_ENDPOINT="$(read_coolify_env COOLIFY_COOLD_VM_WG_PEER_ENDPOINT "")"
|
||||
WG_PEER_PUBLIC_KEY="$(read_coolify_env COOLIFY_COOLD_VM_WG_PEER_PUBLIC_KEY "")"
|
||||
CONTAINER_SUBNET="$(read_coolify_env COOLIFY_COOLD_VM_CONTAINER_SUBNET 10.210.0.0/24)"
|
||||
CONTAINER_GATEWAY="$(read_coolify_env COOLIFY_COOLD_VM_CONTAINER_GATEWAY 10.210.0.1)"
|
||||
TEMPLATE="$ROOT/dev/lima/coold.yaml"
|
||||
GUEST_COOLIFY_ROOT="/workspace/coolify"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: scripts/coold-vm.sh <command>
|
||||
|
||||
Commands:
|
||||
up Create/start the Lima VM with minimal server prerequisites
|
||||
dev Start packaged coold + Corrosion inside the VM
|
||||
start-agent
|
||||
Start production-like coold.service + corrosion.service inside the VM
|
||||
stop-agent
|
||||
Stop the VM coold.service + corrosion.service
|
||||
logs-agent
|
||||
Follow the VM coold.service + corrosion.service logs
|
||||
install-host-jwt [token]
|
||||
Install a Flux host JWT into the VM at /etc/coolify/host-jwt
|
||||
shell Open a shell inside the VM
|
||||
status Show Lima instance status
|
||||
stop Stop the VM
|
||||
delete Delete the VM and all VM-local runtime state
|
||||
|
||||
Environment:
|
||||
COOLIFY_COOLD_LIMA_INSTANCE Override Lima instance name (default: coold-dev)
|
||||
COOLIFY_COOLD_VERSION coold release tag to install (default: nightly)
|
||||
COOLIFY_CORROSION_VERSION corrosion release tag to install (default: v1.0.0)
|
||||
COOLIFY_COOLD_VM_FLUX_URL Flux gRPC URL visible from the VM (default: http://host.lima.internal:6443)
|
||||
COOLIFY_COOLD_VM_START_TIMEOUT Seconds to wait for Lima SSH/provisioning (default: 300)
|
||||
COOLIFY_COOLD_VM_WG_IP Optional WireGuard mgmt IP for this host
|
||||
COOLIFY_COOLD_VM_CONTAINER_SUBNET Podman mesh subnet for this host
|
||||
COOLIFY_COOLD_VM_CONTAINER_GATEWAY Podman mesh gateway for this host
|
||||
|
||||
Guest mounts:
|
||||
$ROOT -> $GUEST_COOLIFY_ROOT
|
||||
|
||||
Installed from:
|
||||
https://github.com/coollabsio/coold/releases/tag/$VERSION
|
||||
https://github.com/superfly/corrosion/releases/tag/$CORROSION_VERSION
|
||||
USAGE
|
||||
}
|
||||
|
||||
require_lima() {
|
||||
command -v limactl >/dev/null 2>&1 || {
|
||||
echo "limactl is required. Install Lima first: brew install lima" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
instance_exists() {
|
||||
limactl list 2>/dev/null | awk 'NR > 1 {print $1}' | grep -qx "$INSTANCE"
|
||||
}
|
||||
|
||||
instance_running() {
|
||||
limactl list 2>/dev/null | awk -v name="$INSTANCE" 'NR > 1 && $1 == name {print $2}' | grep -qx Running
|
||||
}
|
||||
|
||||
lima_shell() {
|
||||
(cd /tmp && limactl shell "$INSTANCE" -- "$@")
|
||||
}
|
||||
|
||||
kill_matching_processes() {
|
||||
local pattern="$1"
|
||||
local pids
|
||||
|
||||
pids="$(pgrep -f "$pattern" 2>/dev/null || true)"
|
||||
if [ -z "$pids" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
kill $pids >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
kill -9 $pids >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
cleanup_lima_probe_processes() {
|
||||
kill_matching_processes "limactl shell ${INSTANCE} -- true"
|
||||
kill_matching_processes "ssh .*ControlPath=.*${INSTANCE}/ssh.sock"
|
||||
kill_matching_processes "ssh: .*/.lima/${INSTANCE}/ssh.sock"
|
||||
rm -f "$HOME/.lima/${INSTANCE}/ssh.sock"
|
||||
}
|
||||
|
||||
cleanup_lima_hostagent_processes() {
|
||||
kill_matching_processes "limactl hostagent .*${INSTANCE}"
|
||||
rm -f "$HOME/.lima/${INSTANCE}/ha.sock"
|
||||
}
|
||||
|
||||
lima_shell_timeout() {
|
||||
local timeout_seconds="$1"
|
||||
shift
|
||||
local pid
|
||||
local elapsed=0
|
||||
|
||||
lima_shell "$@" &
|
||||
pid="$!"
|
||||
|
||||
while kill -0 "$pid" >/dev/null 2>&1; do
|
||||
if [ "$elapsed" -ge "$timeout_seconds" ]; then
|
||||
pkill -P "$pid" >/dev/null 2>&1 || true
|
||||
kill "$pid" >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
pkill -P "$pid" >/dev/null 2>&1 || true
|
||||
kill -9 "$pid" >/dev/null 2>&1 || true
|
||||
wait "$pid" >/dev/null 2>&1 || true
|
||||
cleanup_lima_probe_processes
|
||||
return 124
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
|
||||
wait "$pid"
|
||||
}
|
||||
|
||||
vm_primary_ip() {
|
||||
lima_shell sh -lc "ip -4 route get 1.1.1.1 | awk '{print \$7; exit}'"
|
||||
}
|
||||
|
||||
wireguard_public_key() {
|
||||
lima_shell sudo sh -lc 'install -d -m 0700 /etc/wireguard; if [ ! -s /etc/wireguard/privatekey ]; then wg genkey | tee /etc/wireguard/privatekey | wg pubkey > /etc/wireguard/publickey; chmod 600 /etc/wireguard/privatekey; fi; cat /etc/wireguard/publickey'
|
||||
}
|
||||
|
||||
setup_wireguard() {
|
||||
local ip="${1:-$WG_IP}"
|
||||
local peer_ip="${2:-$WG_PEER_IP}"
|
||||
local peer_endpoint="${3:-$WG_PEER_ENDPOINT}"
|
||||
local peer_public_key="${4:-$WG_PEER_PUBLIC_KEY}"
|
||||
local listen_port="${5:-51820}"
|
||||
local peer_port="${6:-51820}"
|
||||
local peer_subnet="${7:-}"
|
||||
|
||||
if [ -z "$ip" ]; then
|
||||
echo "ERROR: WireGuard IP is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
wireguard_public_key >/dev/null
|
||||
|
||||
if [ -n "$peer_ip" ] && [ -n "$peer_endpoint" ] && [ -n "$peer_public_key" ]; then
|
||||
lima_shell sudo sh -lc "cat >/etc/wireguard/wg0.conf.tmp <<WG
|
||||
[Interface]
|
||||
Address = ${ip}/32
|
||||
ListenPort = ${listen_port}
|
||||
PrivateKey = \$(cat /etc/wireguard/privatekey)
|
||||
|
||||
[Peer]
|
||||
PublicKey = ${peer_public_key}
|
||||
AllowedIPs = ${peer_ip}/32${peer_subnet:+, ${peer_subnet}}
|
||||
Endpoint = ${peer_endpoint}:${peer_port}
|
||||
PersistentKeepalive = 5
|
||||
WG
|
||||
chmod 600 /etc/wireguard/wg0.conf.tmp && mv /etc/wireguard/wg0.conf.tmp /etc/wireguard/wg0.conf && wg-quick down wg0 >/dev/null 2>&1 || true; wg-quick up wg0"
|
||||
else
|
||||
lima_shell sudo sh -lc "cat >/etc/wireguard/wg0.conf.tmp <<WG
|
||||
[Interface]
|
||||
Address = ${ip}/32
|
||||
ListenPort = ${listen_port}
|
||||
PrivateKey = \$(cat /etc/wireguard/privatekey)
|
||||
WG
|
||||
chmod 600 /etc/wireguard/wg0.conf.tmp && mv /etc/wireguard/wg0.conf.tmp /etc/wireguard/wg0.conf && wg-quick down wg0 >/dev/null 2>&1 || true; wg-quick up wg0"
|
||||
fi
|
||||
}
|
||||
|
||||
install_host_jwt() {
|
||||
token="${1:-}"
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
token="$(cat)"
|
||||
fi
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
echo "ERROR: host JWT is empty." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$token" | lima_shell sudo sh -c 'install -d -m 0755 /etc/coolify && cat > /tmp/coolify-host-jwt && install -m 0600 /tmp/coolify-host-jwt /etc/coolify/host-jwt && rm -f /tmp/coolify-host-jwt'
|
||||
}
|
||||
|
||||
|
||||
stop_agent_processes() {
|
||||
lima_shell sudo systemctl stop coold.service corrosion.service coold-dev-agent.service >/dev/null 2>&1 || true
|
||||
lima_shell sudo pkill -x coold >/dev/null 2>&1 || true
|
||||
lima_shell sudo pkill -x corrosion >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
ensure_podman_networks() {
|
||||
local current_subnet
|
||||
current_subnet="$(lima_shell sudo podman network inspect coolify-default-mesh --format '{{range .Subnets}}{{.Subnet}}{{end}}' 2>/dev/null || true)"
|
||||
|
||||
if [ -n "$current_subnet" ] && [ "$current_subnet" != "$CONTAINER_SUBNET" ]; then
|
||||
lima_shell sudo podman network rm coolify-default-mesh >/dev/null
|
||||
current_subnet=""
|
||||
fi
|
||||
|
||||
if [ -z "$current_subnet" ]; then
|
||||
lima_shell sudo podman network create --subnet "$CONTAINER_SUBNET" --gateway "$CONTAINER_GATEWAY" coolify-default-mesh >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
|
||||
ensure_mesh_dns_anchor() {
|
||||
lima_shell sudo podman run -d --replace \
|
||||
--name coolify-v5-mesh-dns-anchor \
|
||||
--network coolify-default-mesh \
|
||||
docker.io/library/alpine:3.20 \
|
||||
sleep infinity >/dev/null
|
||||
}
|
||||
|
||||
configure_system_resolved() {
|
||||
lima_shell sudo rm -f /etc/systemd/resolved.conf.d/coolify-internal.conf
|
||||
lima_shell sudo systemctl restart systemd-resolved.service
|
||||
lima_shell sudo resolvectl dns podman1 "$CONTAINER_GATEWAY"
|
||||
lima_shell sudo resolvectl domain podman1 '~coolify.internal'
|
||||
lima_shell sudo resolvectl default-route podman1 false
|
||||
}
|
||||
write_runtime_config() {
|
||||
local gossip_addr="127.0.0.1:8787"
|
||||
local bootstrap=""
|
||||
|
||||
if [ -n "$WG_IP" ]; then
|
||||
gossip_addr="$WG_IP:8787"
|
||||
fi
|
||||
|
||||
if [ -n "$WG_PEER_IP" ]; then
|
||||
bootstrap="\"$WG_PEER_IP:8787\""
|
||||
fi
|
||||
|
||||
lima_shell sudo install -d -m 0755 /etc/corrosion/schemas /etc/coolify /run/coolify /var/lib/corrosion /var/run/corrosion /var/lib/coolify-dev
|
||||
|
||||
lima_shell sudo tee /etc/corrosion/schemas/coolify.sql >/dev/null <<'SQL'
|
||||
CREATE TABLE service_endpoints (
|
||||
container_id TEXT NOT NULL DEFAULT '' PRIMARY KEY,
|
||||
container_name TEXT NOT NULL DEFAULT '',
|
||||
namespace TEXT NOT NULL DEFAULT '',
|
||||
host_mgmt_ip TEXT NOT NULL DEFAULT '',
|
||||
container_ip TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
health TEXT NOT NULL DEFAULT 'unknown',
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
SQL
|
||||
|
||||
lima_shell sudo tee /etc/corrosion/config.toml >/dev/null <<TOML
|
||||
[db]
|
||||
path = "/var/lib/corrosion/corrosion.db"
|
||||
schema_paths = ["/etc/corrosion/schemas"]
|
||||
|
||||
[gossip]
|
||||
addr = "$gossip_addr"
|
||||
bootstrap = [$bootstrap]
|
||||
plaintext = true
|
||||
|
||||
[api]
|
||||
addr = "127.0.0.1:8080"
|
||||
|
||||
[admin]
|
||||
path = "/var/run/corrosion/admin.sock"
|
||||
TOML
|
||||
|
||||
}
|
||||
|
||||
run_foreground() {
|
||||
stop_agent_processes
|
||||
write_runtime_config
|
||||
ensure_podman_networks
|
||||
configure_system_resolved
|
||||
ensure_mesh_dns_anchor
|
||||
install_mesh_firewall
|
||||
|
||||
(cd /tmp && limactl shell "$INSTANCE" -- sudo \
|
||||
env COOLIFY_COOLD_HOST_MGMT_IP="${WG_IP:-127.0.0.1}" \
|
||||
COOLIFY_COOLD_FLUX_URL="$FLUX_URL" \
|
||||
CONTAINER_GATEWAY="$CONTAINER_GATEWAY" \
|
||||
bash -s) <<'RUNNER'
|
||||
set -euo pipefail
|
||||
|
||||
echo "coold: $(/usr/local/bin/coold --version)"
|
||||
echo "corrosion: $(/usr/local/bin/corrosion --version 2>/dev/null || cat /usr/local/bin/corrosion.version)"
|
||||
echo "starting packaged coold endpoint with corrosion in local mode"
|
||||
|
||||
cleanup() {
|
||||
jobs -pr | xargs -r kill || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
/usr/local/bin/corrosion agent --config /etc/corrosion/config.toml &
|
||||
|
||||
COOLIFY_COOLD_HOST_MGMT_IP="${COOLIFY_COOLD_HOST_MGMT_IP:-127.0.0.1}" \
|
||||
COOLIFY_COOLD_PODMAN_SOCKET="${COOLIFY_COOLD_PODMAN_SOCKET:-/run/podman/podman.sock}" \
|
||||
COOLIFY_COOLD_CORROSION_URL="${COOLIFY_COOLD_CORROSION_URL:-http://127.0.0.1:8080}" \
|
||||
COOLIFY_COOLD_NAMESPACES="${COOLIFY_COOLD_NAMESPACES:-default:coolify-default-mesh:$CONTAINER_GATEWAY}" \
|
||||
COOLIFY_COOLD_DNS_ZONE="${COOLIFY_COOLD_DNS_ZONE:-coolify.internal}" \
|
||||
COOLIFY_COOLD_FLUX_URL="${COOLIFY_COOLD_FLUX_URL:-http://host.lima.internal:6443}" \
|
||||
COOLIFY_COOLD_HOST_JWT_PATH="${COOLIFY_COOLD_HOST_JWT_PATH:-/etc/coolify/host-jwt}" \
|
||||
/usr/local/bin/coold &
|
||||
|
||||
wait
|
||||
RUNNER
|
||||
}
|
||||
|
||||
install_mesh_firewall() {
|
||||
lima_shell sudo install -d -m 0755 /etc/coolify
|
||||
lima_shell sudo touch /etc/coolify/allow.rules /etc/coolify/allow.nft
|
||||
|
||||
lima_shell sudo tee /etc/coolify/bridge-fw.nft >/dev/null <<NFT
|
||||
add table bridge coolify_bridge
|
||||
add chain bridge coolify_bridge coolify_allow
|
||||
flush chain bridge coolify_bridge coolify_allow
|
||||
add chain bridge coolify_bridge coolify_intra
|
||||
flush chain bridge coolify_bridge coolify_intra
|
||||
add rule bridge coolify_bridge coolify_intra jump coolify_allow
|
||||
add rule bridge coolify_bridge coolify_intra drop
|
||||
add chain bridge coolify_bridge forward { type filter hook forward priority -200; policy accept; }
|
||||
flush chain bridge coolify_bridge forward
|
||||
add rule bridge coolify_bridge forward meta protocol != ip accept
|
||||
add rule bridge coolify_bridge forward ct state established,related accept
|
||||
add rule bridge coolify_bridge forward ip saddr { $CONTAINER_SUBNET } jump coolify_intra
|
||||
add rule bridge coolify_bridge forward ip daddr { $CONTAINER_SUBNET } jump coolify_intra
|
||||
NFT
|
||||
|
||||
lima_shell sudo tee /etc/systemd/system/coolify-mesh-fw.service >/dev/null <<UNIT
|
||||
[Unit]
|
||||
Description=Coolify mesh firewall rules
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/sbin/sysctl -w net.ipv4.ip_forward=1
|
||||
ExecStart=/bin/sh -c "/usr/sbin/iptables -t nat -C POSTROUTING -s $CONTAINER_SUBNET -o wg0 -j RETURN 2>/dev/null || /usr/sbin/iptables -t nat -I POSTROUTING -s $CONTAINER_SUBNET -o wg0 -j RETURN"
|
||||
ExecStart=/bin/sh -c "/usr/sbin/iptables -D FORWARD -s $CONTAINER_SUBNET -j ACCEPT 2>/dev/null || true"
|
||||
ExecStart=/bin/sh -c "/usr/sbin/iptables -D FORWARD -d $CONTAINER_SUBNET -j ACCEPT 2>/dev/null || true"
|
||||
ExecStart=/bin/sh -c "/usr/sbin/iptables -N COOLIFY-ALLOW 2>/dev/null || true"
|
||||
ExecStart=/bin/sh -c "/usr/sbin/iptables -N COOLIFY-INTRA 2>/dev/null || true"
|
||||
ExecStart=/usr/sbin/iptables -F COOLIFY-ALLOW
|
||||
ExecStart=/usr/sbin/iptables -F COOLIFY-INTRA
|
||||
ExecStart=/usr/sbin/iptables -A COOLIFY-INTRA -j COOLIFY-ALLOW
|
||||
ExecStart=/usr/sbin/iptables -A COOLIFY-INTRA -j DROP
|
||||
ExecStart=/bin/sh -c "/usr/sbin/iptables -C FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || /usr/sbin/iptables -I FORWARD 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT"
|
||||
ExecStart=/bin/sh -c "/usr/sbin/iptables -C FORWARD -d $CONTAINER_SUBNET -j COOLIFY-INTRA 2>/dev/null || /usr/sbin/iptables -A FORWARD -d $CONTAINER_SUBNET -j COOLIFY-INTRA"
|
||||
ExecStart=/bin/sh -c "/usr/sbin/iptables -C FORWARD -s $CONTAINER_SUBNET -j COOLIFY-INTRA 2>/dev/null || /usr/sbin/iptables -A FORWARD -s $CONTAINER_SUBNET -j COOLIFY-INTRA"
|
||||
ExecStart=/bin/sh -c "nft delete table bridge coolify_bridge 2>/dev/null || true"
|
||||
ExecStart=/bin/sh -c "nft -f /etc/coolify/bridge-fw.nft"
|
||||
ExecStart=/bin/sh -c "[ -s /etc/coolify/allow.nft ] && nft -f /etc/coolify/allow.nft || true"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
lima_shell sudo systemctl daemon-reload
|
||||
lima_shell sudo systemctl enable --now coolify-mesh-fw.service
|
||||
lima_shell sudo systemctl restart coolify-mesh-fw.service
|
||||
}
|
||||
|
||||
start_agent() {
|
||||
stop_agent_processes
|
||||
write_runtime_config
|
||||
ensure_podman_networks
|
||||
configure_system_resolved
|
||||
ensure_mesh_dns_anchor
|
||||
install_mesh_firewall
|
||||
|
||||
lima_shell sudo tee /etc/systemd/system/corrosion.service >/dev/null <<'UNIT'
|
||||
[Unit]
|
||||
Description=Corrosion local state store
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/corrosion agent --config /etc/corrosion/config.toml
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
lima_shell sudo tee /etc/systemd/system/coold.service >/dev/null <<UNIT
|
||||
[Unit]
|
||||
Description=Coolify host agent
|
||||
Wants=corrosion.service
|
||||
After=corrosion.service network-online.target podman.socket coolify-mesh-fw.service
|
||||
|
||||
[Service]
|
||||
Environment=COOLIFY_COOLD_HOST_MGMT_IP=${WG_IP:-127.0.0.1}
|
||||
Environment=COOLIFY_COOLD_PODMAN_SOCKET=/run/podman/podman.sock
|
||||
Environment=COOLIFY_COOLD_CORROSION_URL=http://127.0.0.1:8080
|
||||
Environment=COOLIFY_COOLD_NAMESPACES=default:coolify-default-mesh:$CONTAINER_GATEWAY
|
||||
Environment=COOLIFY_COOLD_DNS_ZONE=coolify.internal
|
||||
Environment=COOLIFY_COOLD_FLUX_URL=$FLUX_URL
|
||||
Environment=COOLIFY_COOLD_HOST_JWT_PATH=/etc/coolify/host-jwt
|
||||
ExecStart=/usr/local/bin/coold
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_ADMIN CAP_NET_RAW
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
lima_shell sudo systemctl daemon-reload
|
||||
lima_shell sudo systemctl enable --now corrosion.service coold.service
|
||||
lima_shell sudo systemctl restart corrosion.service coold.service
|
||||
}
|
||||
|
||||
start_vm() {
|
||||
if instance_running; then
|
||||
return
|
||||
fi
|
||||
|
||||
cleanup_lima_probe_processes
|
||||
cleanup_lima_hostagent_processes
|
||||
|
||||
if instance_exists; then
|
||||
limactl start --tty=false "$INSTANCE"
|
||||
else
|
||||
limactl start --tty=false --name="$INSTANCE" "$TEMPLATE"
|
||||
fi
|
||||
}
|
||||
|
||||
latest_lima_message() {
|
||||
log_file="$HOME/.lima/$INSTANCE/ha.stderr.log"
|
||||
|
||||
if [ ! -f "$log_file" ]; then
|
||||
echo "creating Lima instance directory"
|
||||
return 0
|
||||
fi
|
||||
|
||||
grep -E '"msg":"(Starting VZ|Waiting for|The essential requirement|Executing /mnt/lima|SSH Local Port|Port is available|Attempting|Downloaded|Using the existing instance|The instance)' "$log_file" | tail -n 1 | sed -E 's/^.*"msg":"(.*)","time":.*$/\1/' | sed 's/\\"/"/g' || true
|
||||
}
|
||||
|
||||
wait_for_lima_start() {
|
||||
start_vm &
|
||||
start_pid=$!
|
||||
elapsed=0
|
||||
|
||||
while kill -0 "$start_pid" 2>/dev/null; do
|
||||
if [ "$elapsed" -ge "$START_TIMEOUT" ]; then
|
||||
echo "ERROR: Lima start timed out after ${START_TIMEOUT}s for ${INSTANCE}." >&2
|
||||
kill "$start_pid" >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
kill -9 "$start_pid" >/dev/null 2>&1 || true
|
||||
wait "$start_pid" >/dev/null 2>&1 || true
|
||||
return 124
|
||||
fi
|
||||
|
||||
if instance_exists && lima_shell_timeout 5 true >/dev/null 2>&1; then
|
||||
status="$(lima_shell cloud-init status 2>/dev/null || true)"
|
||||
printf '==> [%3ss] Lima start: guest SSH ready, cloud-init %s
|
||||
' "$elapsed" "${status:-unknown}"
|
||||
lima_shell sudo sh -c 'test -f /var/log/cloud-init-output.log && tail -n 12 /var/log/cloud-init-output.log || true' 2>/dev/null | awk '{ print "[guest] " $0; fflush(); }' || true
|
||||
|
||||
if ! printf '%s' "$status" | grep -q running; then
|
||||
if kill -0 "$start_pid" 2>/dev/null; then
|
||||
kill "$start_pid" >/dev/null 2>&1 || true
|
||||
disown "$start_pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
return
|
||||
fi
|
||||
else
|
||||
message="$(latest_lima_message)"
|
||||
printf '==> [%3ss] Lima start: %s
|
||||
' "$elapsed" "${message:-booting}"
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
|
||||
wait "$start_pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
wait_for_guest_provisioning() {
|
||||
echo "==> Waiting for guest SSH..."
|
||||
elapsed=0
|
||||
until instance_exists && lima_shell_timeout 5 true >/dev/null 2>&1; do
|
||||
if [ "$elapsed" -ge "$START_TIMEOUT" ]; then
|
||||
echo "ERROR: Guest SSH timed out after ${START_TIMEOUT}s for ${INSTANCE}." >&2
|
||||
return 124
|
||||
fi
|
||||
|
||||
message="$(latest_lima_message)"
|
||||
printf '==> Waiting for guest SSH: %s
|
||||
' "${message:-booting}"
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
|
||||
status="$(lima_shell cloud-init status 2>/dev/null || true)"
|
||||
|
||||
if printf '%s' "$status" | grep -q running; then
|
||||
echo "==> Guest SSH is ready; streaming cloud-init output until provisioning completes..."
|
||||
(
|
||||
lima_shell sudo sh -c 'touch /var/log/cloud-init-output.log; tail -n 40 -F /var/log/cloud-init-output.log' 2>/dev/null | awk '{ print "[guest] " $0; fflush(); }'
|
||||
) &
|
||||
tail_pid=$!
|
||||
|
||||
while true; do
|
||||
if [ "$elapsed" -ge "$START_TIMEOUT" ]; then
|
||||
echo "ERROR: Guest provisioning timed out after ${START_TIMEOUT}s for ${INSTANCE}." >&2
|
||||
kill "$tail_pid" >/dev/null 2>&1 || true
|
||||
wait "$tail_pid" 2>/dev/null || true
|
||||
return 124
|
||||
fi
|
||||
|
||||
status="$(lima_shell cloud-init status 2>/dev/null || true)"
|
||||
printf '==> Guest cloud-init: %s
|
||||
' "${status:-unknown}"
|
||||
|
||||
if ! printf '%s' "$status" | grep -q running; then
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
|
||||
kill "$tail_pid" >/dev/null 2>&1 || true
|
||||
wait "$tail_pid" 2>/dev/null || true
|
||||
else
|
||||
printf '==> Guest cloud-init: %s
|
||||
' "${status:-unknown}"
|
||||
fi
|
||||
|
||||
echo "==> Final guest provisioning status:"
|
||||
lima_shell bash -lc 'cloud-init status 2>/dev/null || true; echo "minimal VM ready"; true' \
|
||||
| awk '{ print "[guest] " $0; fflush(); }' || true
|
||||
}
|
||||
|
||||
ensure_mdns_hostname() {
|
||||
local current_hostname
|
||||
current_hostname="$(lima_shell hostname 2>/dev/null || true)"
|
||||
|
||||
if [ "$current_hostname" != "$INSTANCE" ]; then
|
||||
echo "==> Setting guest hostname to ${INSTANCE} for ${INSTANCE}.local mDNS..."
|
||||
lima_shell sudo hostnamectl set-hostname "$INSTANCE"
|
||||
fi
|
||||
|
||||
lima_shell sudo systemctl restart avahi-daemon.service
|
||||
}
|
||||
|
||||
up_with_logs() {
|
||||
echo "==> Coolify coold VM: $INSTANCE"
|
||||
echo "==> coold package tag: $VERSION"
|
||||
echo "==> corrosion package tag: $CORROSION_VERSION"
|
||||
echo "==> Lima config: $TEMPLATE"
|
||||
|
||||
wait_for_lima_start
|
||||
wait_for_guest_provisioning
|
||||
ensure_mdns_hostname
|
||||
|
||||
echo "==> VM is ready. Run coolify bootstrap via: scripts/dev.sh up"
|
||||
}
|
||||
|
||||
cmd="${1:-}"
|
||||
case "$cmd" in
|
||||
up)
|
||||
require_lima
|
||||
up_with_logs
|
||||
;;
|
||||
dev)
|
||||
require_lima
|
||||
start_vm >/dev/null
|
||||
run_foreground
|
||||
;;
|
||||
start-agent)
|
||||
require_lima
|
||||
start_vm >/dev/null
|
||||
start_agent
|
||||
;;
|
||||
stop-agent)
|
||||
require_lima
|
||||
if instance_running; then
|
||||
stop_agent_processes
|
||||
fi
|
||||
;;
|
||||
logs-agent)
|
||||
require_lima
|
||||
start_vm >/dev/null
|
||||
exec bash -lc "cd /tmp && limactl shell '$INSTANCE' -- sudo journalctl -u coold.service -u corrosion.service -f -n 100"
|
||||
;;
|
||||
install-host-jwt)
|
||||
require_lima
|
||||
start_vm >/dev/null
|
||||
install_host_jwt "${2:-}"
|
||||
;;
|
||||
wg-public-key)
|
||||
require_lima
|
||||
start_vm >/dev/null
|
||||
wireguard_public_key
|
||||
;;
|
||||
vm-ip)
|
||||
require_lima
|
||||
start_vm >/dev/null
|
||||
vm_primary_ip
|
||||
;;
|
||||
setup-wireguard)
|
||||
require_lima
|
||||
start_vm >/dev/null
|
||||
setup_wireguard "${2:-}" "${3:-}" "${4:-}" "${5:-}" "${6:-}" "${7:-}" "${8:-}"
|
||||
;;
|
||||
shell)
|
||||
require_lima
|
||||
start_vm >/dev/null
|
||||
exec bash -lc "cd /tmp && limactl shell '$INSTANCE' -- sudo env TERM=xterm-256color SYSTEMD_PAGER=cat SYSTEMD_LESS=FRXMK bash -l"
|
||||
;;
|
||||
status)
|
||||
require_lima
|
||||
if instance_exists; then
|
||||
exec limactl list "$INSTANCE"
|
||||
fi
|
||||
|
||||
echo "No Lima instance named '$INSTANCE' exists yet."
|
||||
echo "Run: scripts/coold-vm.sh up"
|
||||
;;
|
||||
stop)
|
||||
require_lima
|
||||
exec limactl stop "$INSTANCE"
|
||||
;;
|
||||
delete|destroy)
|
||||
require_lima
|
||||
exec limactl delete --force --tty=false "$INSTANCE"
|
||||
;;
|
||||
-h|--help|help|"")
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "unknown command: $cmd" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
it('does not expose removed dev firewall commands', function () {
|
||||
$script = file_get_contents(base_path('scripts/dev.sh'));
|
||||
|
||||
expect($script)->not->toContain('coolify_firewall()')
|
||||
->and($script)->not->toContain('scripts/dev.sh firewall')
|
||||
->and($script)->not->toContain('firewall <command>')
|
||||
->and($script)->not->toContain('/api/v1/firewall/allow')
|
||||
->and($script)->not->toContain('firewall_api_for_each_vm');
|
||||
});
|
||||
|
||||
it('installs the coolify CLI in both application container images', function (string $dockerfile) {
|
||||
$contents = file_get_contents(base_path($dockerfile));
|
||||
|
||||
expect($contents)->toContain('ARG COOLIFY_CLI_VERSION=nightly')
|
||||
->and($contents)->toContain('coolify-linux-musl-${COOLIFY_CLI_ARCH}.tar.gz')
|
||||
->and($contents)->toContain('install -m 0755 /tmp/coolify /usr/local/bin/coolify');
|
||||
})->with([
|
||||
'development image' => 'docker/development/Dockerfile',
|
||||
'production image' => 'docker/production/Dockerfile',
|
||||
]);
|
||||
|
||||
it('runs the coolify CLI from the development application container', function () {
|
||||
$script = file_get_contents(base_path('scripts/dev.sh'));
|
||||
|
||||
expect($script)->toContain("printf '%s\\n' '/usr/local/bin/coolify'")
|
||||
->and($script)->toContain('docker compose -f docker-compose.yml -f docker-compose.dev.yml exec -T coolify "$(coolify_cli_bin)" init bootstrap')
|
||||
->and($script)->toContain('docker compose -f docker-compose.yml -f docker-compose.dev.yml exec -T coolify "$(coolify_cli_bin)" "$@"')
|
||||
->and($script)->toContain('ensure_coolify_container_ssh_key')
|
||||
->and($script)->not->toContain('.dev/bin/coolify')
|
||||
->and($script)->not->toContain('coolify-${os}-${arch}.tar.gz');
|
||||
});
|
||||
|
||||
it('syncs host-resolved Lima local names into the Coolify container hosts file', function () {
|
||||
$script = file_get_contents(base_path('scripts/dev.sh'));
|
||||
|
||||
expect($script)->toContain('resolve_lima_dns_name()')
|
||||
->and($script)->toContain('dscacheutil -q host -a name "$name"')
|
||||
->and($script)->toContain('getent ahostsv4 "$name"')
|
||||
->and($script)->toContain('sync_lima_hosts_into_coolify_container()')
|
||||
->and($script)->toContain('docker compose -f docker-compose.yml -f docker-compose.dev.yml exec -T -u root coolify sh -lc')
|
||||
->and($script)->toContain('cat "$next" > /etc/hosts')
|
||||
->and($script)->toContain('sync_lima_hosts_into_coolify_container')
|
||||
->and($script)->toContain('if [ "$naked" = "true" ]; then');
|
||||
});
|
||||
|
||||
it('does not require predefined UI node environment variables in the development app container', function () {
|
||||
$compose = file_get_contents(base_path('docker-compose.dev.yml'));
|
||||
$config = file_get_contents(base_path('config/coold.php'));
|
||||
|
||||
expect($compose)->not->toContain('COOLIFY_CLI_NODES:')
|
||||
->and($compose)->not->toContain('COOLIFY_CLI_SSH_CONFIG:')
|
||||
->and($compose)->not->toContain('COOLIFY_CLI_WG_LISTEN_PORT_OVERRIDES:')
|
||||
->and($compose)->not->toContain('COOLIFY_CLI_WG_ENDPOINT_OVERRIDES:')
|
||||
->and($compose)->not->toContain('COOLIFY_CLI_TIMEOUT:')
|
||||
->and($config)->not->toContain('cli_nodes')
|
||||
->and($config)->not->toContain('COOLIFY_CLI_NODES');
|
||||
});
|
||||
|
||||
it('supports a naked up mode that starts VMs and Docker Compose but skips server bootstrap wiring', function () {
|
||||
$script = file_get_contents(base_path('scripts/dev.sh'));
|
||||
|
||||
expect($script)->toContain('local naked=false')
|
||||
->and($script)->toContain('--naked')
|
||||
->and($script)->toContain('if [ "${#compose_args[@]}" -gt 0 ]; then')
|
||||
->and($script)->toContain('docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d "${compose_args[@]}"')
|
||||
->and($script)->toContain('docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d')
|
||||
->and($script)->toContain('if [ "$naked" = "true" ]; then')
|
||||
->and($script)->toContain('Skipping coolify bootstrap and Flux VM wiring')
|
||||
->and($script)->toContain('coolify_bootstrap_with_retry')
|
||||
->and($script)->toContain('--concurrency "$(coolify_bootstrap_concurrency)"')
|
||||
->and($script)->toContain('--ssh-timeout "$(coolify_bootstrap_ssh_timeout)"')
|
||||
->and($script)->toContain('--debug')
|
||||
->and($script)->toContain('configure_flux_dev_for_vm "$index"')
|
||||
->and($script)->toContain('sync_v5_dev_lima_servers');
|
||||
});
|
||||
|
||||
it('retries dev coolify bootstrap because fresh Lima setup can complete across partial phases', function () {
|
||||
$script = file_get_contents(base_path('scripts/dev.sh'));
|
||||
|
||||
expect($script)->toContain('coolify_bootstrap_with_retry()')
|
||||
->and($script)->toContain('local attempts=5')
|
||||
->and($script)->toContain('if coolify_bootstrap; then')
|
||||
->and($script)->toContain('diagnose_coold_bootstrap_failure')
|
||||
->and($script)->toContain('systemctl --failed --no-pager')
|
||||
->and($script)->toContain('systemctl --no-pager --full status wg-quick@wg0 podman.socket corrosion coold')
|
||||
->and($script)->toContain('fresh Lima hosts can finish setup after partial bootstrap phases');
|
||||
});
|
||||
|
||||
it('seeds bootstrapped Lima VMs into v5 development server state', function () {
|
||||
$script = file_get_contents(base_path('scripts/dev.sh'));
|
||||
$compose = file_get_contents(base_path('docker-compose.dev.yml'));
|
||||
|
||||
expect($script)->toContain('sync_v5_dev_lima_servers()')
|
||||
->and($script)->toContain('COOLIFY_CLI_SSH_USER="$ssh_user"')
|
||||
->and($script)->toContain('COOLIFY_CLI_SSH_USER="$(coolify_ssh_user)" docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d')
|
||||
->and($script)->toContain('coold_vm_dns_name()')
|
||||
->and($script)->toContain('$(coold_vm_dns_name "$index")')
|
||||
->and($script)->toContain('v5:sync-dev-lima-servers')
|
||||
->and($script)->toContain('--server="${instance}|$(coold_vm_dns_name "$index")|${ssh_user}|22|$(coold_vm_wg_ip "$index")"')
|
||||
->and($compose)->toContain('COOLIFY_CLI_SSH_USER: "${COOLIFY_CLI_SSH_USER:-}"')
|
||||
->and($script)->not->toContain('db:seed --class=V5DevLimaSeeder --force')
|
||||
->and($script)->not->toContain('host.docker.internal|${ssh_user}');
|
||||
});
|
||||
|
||||
it('uses the shared Lima template directly instead of generated per-VM YAML', function () {
|
||||
$script = file_get_contents(base_path('scripts/dev.sh'));
|
||||
$vmScript = file_get_contents(base_path('scripts/coold-vm.sh'));
|
||||
$template = file_get_contents(base_path('dev/lima/coold.yaml'));
|
||||
|
||||
expect($script)->toContain('coold_vm_dns_name()')
|
||||
->and($script)->not->toContain('COOLIFY_COOLD_VM_SSH_PORT="$(coold_vm_ssh_port "$index")"')
|
||||
->and($vmScript)->toContain('limactl start --tty=false --name="$INSTANCE" "$TEMPLATE"')
|
||||
->and($vmScript)->not->toContain('GENERATED=')
|
||||
->and($vmScript)->not->toContain('generate_yaml()')
|
||||
->and($template)->not->toContain('{{COOLIFY_COOLD_VM_SSH_PORT}}');
|
||||
});
|
||||
|
||||
it('sets the Lima guest hostname to the instance name for predictable mDNS', function () {
|
||||
$vmScript = file_get_contents(base_path('scripts/coold-vm.sh'));
|
||||
$template = file_get_contents(base_path('dev/lima/coold.yaml'));
|
||||
|
||||
expect($vmScript)->toContain('ensure_mdns_hostname()')
|
||||
->and($vmScript)->toContain('hostnamectl set-hostname "$INSTANCE"')
|
||||
->and($vmScript)->toContain('systemctl restart avahi-daemon.service')
|
||||
->and($vmScript)->toContain('ensure_mdns_hostname')
|
||||
->and($template)->toContain('hostnamectl set-hostname "{{.Name}}"')
|
||||
->and($template)->not->toContain('hostnamectl set-hostname myvm');
|
||||
});
|
||||
|
||||
it('authorizes the seeded testing host key in dev Lima VMs', function () {
|
||||
$template = file_get_contents(base_path('dev/lima/coold.yaml'));
|
||||
|
||||
expect($template)->toContain('coolify_test_public_key=')
|
||||
->and($template)->toContain('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68')
|
||||
->and($template)->toContain('authorized_keys')
|
||||
->and($template)->toContain('grep -qxF "$coolify_test_public_key"')
|
||||
->and($template)->toContain('install_public_key root')
|
||||
->and($template)->toContain('install_public_key coolify')
|
||||
->and($template)->toContain('owner="$(stat -c \'%u:%g\' "$home")"')
|
||||
->and($template)->toContain('hostnamectl set-hostname "{{.Name}}"')
|
||||
->and($template)->not->toContain('hostnamectl set-hostname myvm')
|
||||
->and($template)->not->toContain('mode: user')
|
||||
->and($template)->not->toContain('user="$(basename "$home")"');
|
||||
});
|
||||
|
||||
it('hardcodes the naked Lima VM ssh port for bootstrap testing', function () {
|
||||
$template = file_get_contents(base_path('.dev/lima/coolify-naked-test.yaml'));
|
||||
|
||||
expect($template)->toContain('ssh:')
|
||||
->and($template)->toContain('localPort: 60003');
|
||||
});
|
||||
|
||||
it('supports down cleanup as the preferred VM cleanup command', function () {
|
||||
$script = file_get_contents(base_path('scripts/dev.sh'));
|
||||
|
||||
expect($script)->toContain('local cleanup=false')
|
||||
->and($script)->toContain('--cleanup')
|
||||
->and($script)->toContain('if [ "$cleanup" = "true" ]; then')
|
||||
->and($script)->toContain('clean_vms')
|
||||
->and($script)->toContain('down --cleanup')
|
||||
->and($script)->toContain('clean-vms Delete the coold Lima VMs and all VM-local runtime state (alias for down --cleanup)');
|
||||
});
|
||||
|
||||
it('scripts a full fresh dev reset with bounded Lima startup retries', function () {
|
||||
$script = file_get_contents(base_path('scripts/dev.sh'));
|
||||
$vmScript = file_get_contents(base_path('scripts/coold-vm.sh'));
|
||||
|
||||
expect($script)->toContain('fresh()')
|
||||
->and($script)->toContain('down --cleanup')
|
||||
->and($script)->toContain('COOLIFY_DEV_FOLLOW_LOGS=false up')
|
||||
->and($script)->toContain('php artisan migrate:fresh --seed --force')
|
||||
->and($script)->toContain('sync_v5_dev_lima_servers')
|
||||
->and($script)->toContain('recreate_naked_lima_vm')
|
||||
->and($script)->toContain('COOLIFY_NAKED_VM_START_TIMEOUT')
|
||||
->and($script)->toContain('limactl start --tty=false --name="$instance" "$config"')
|
||||
->and($script)->toContain('cleanup_lima_instance_processes "$instance"')
|
||||
->and($script)->toContain('did not become ready after ${attempts} attempts')
|
||||
->and($script)->toContain('refresh_test_host_key')
|
||||
->and($script)->toContain('coold_vm_up_with_retry()')
|
||||
->and($script)->toContain('COOLIFY_COOLD_BOOTSTRAP_CONCURRENCY 1')
|
||||
->and($script)->toContain('COOLIFY_COOLD_BOOTSTRAP_SSH_TIMEOUT 90s')
|
||||
->and($script)->toContain('local attempts=2')
|
||||
->and($script)->toContain('deleting and retrying with a fresh Lima instance')
|
||||
->and($script)->toContain('cleanup_lima_instance_processes()')
|
||||
->and($script)->toContain('kill_matching_processes()')
|
||||
->and($script)->toContain('kill -9 $pids')
|
||||
->and($script)->toContain('limactl hostagent .*${instance}')
|
||||
->and($script)->toContain('ssh: .*/.lima/${instance}/ssh.sock')
|
||||
->and($vmScript)->toContain('COOLIFY_COOLD_VM_START_TIMEOUT')
|
||||
->and($vmScript)->toContain('lima_shell_timeout()')
|
||||
->and($vmScript)->toContain('cleanup_lima_probe_processes()')
|
||||
->and($vmScript)->toContain('cleanup_lima_hostagent_processes()')
|
||||
->and($vmScript)->toContain('kill_matching_processes()')
|
||||
->and($vmScript)->toContain('pkill -P "$pid"')
|
||||
->and($vmScript)->toContain('lima_shell_timeout 5 true')
|
||||
->and($vmScript)->toContain('Lima start timed out after ${START_TIMEOUT}s')
|
||||
->and($vmScript)->toContain('Guest SSH timed out after ${START_TIMEOUT}s')
|
||||
->and($vmScript)->toContain('Guest provisioning timed out after ${START_TIMEOUT}s');
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
it('issues production host tokens with the default capability profile', function () {
|
||||
[$privateKeyPath, $publicKeyPath] = createFluxJwtKeypair();
|
||||
|
||||
Config::set('flux.jwt_private_key_path', $privateKeyPath);
|
||||
|
||||
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
|
||||
$claims = JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'));
|
||||
|
||||
expect($claims->sub)->toBe('100.64.0.10')
|
||||
->and($claims->aud)->toBe('coold')
|
||||
->and((array) $claims->caps)->toBe(config('flux.host_capabilities'))
|
||||
->and((array) $claims->caps)->not->toContain('host-agent:default')
|
||||
->and($claims->exp)->toBeGreaterThan(time());
|
||||
});
|
||||
|
||||
it('issues production server tokens with server identity claims', function () {
|
||||
[$privateKeyPath, $publicKeyPath] = createFluxJwtKeypair();
|
||||
|
||||
Config::set('flux.jwt_private_key_path', $privateKeyPath);
|
||||
|
||||
$server = new V5Server;
|
||||
$server->forceFill([
|
||||
'uuid' => 'server_prod_123',
|
||||
'id' => 123,
|
||||
'team_id' => 7,
|
||||
'cluster_id' => 'cluster-456',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'node_address' => '203.0.113.10',
|
||||
]);
|
||||
|
||||
$token = app(AgentTokenIssuer::class)->issueForServer($server);
|
||||
$claims = JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'));
|
||||
|
||||
expect($claims->sub)->toBe('server_prod_123')
|
||||
->and((array) $claims->caps)->toBe(config('flux.host_capabilities'))
|
||||
->and($claims->team_id)->toBe('7')
|
||||
->and($claims->cluster_id)->toBe('cluster-456')
|
||||
->and($claims->server_id)->toBe('server_prod_123')
|
||||
->and($claims->wireguard_management_ip)->toBe('100.64.0.10');
|
||||
});
|
||||
|
||||
it('mints a host jwt signed by the configured flux private key', function () {
|
||||
[$privateKeyPath, $publicKeyPath] = createFluxJwtKeypair();
|
||||
|
||||
Config::set('flux.jwt_private_key_path', $privateKeyPath);
|
||||
|
||||
$exitCode = Artisan::call('flux:dev', [
|
||||
'host_id' => 'coold-dev',
|
||||
'--caps' => 'containers.list,ingress.apply',
|
||||
'--ttl' => '600',
|
||||
]);
|
||||
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
$token = trim(Artisan::output());
|
||||
$claims = JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'));
|
||||
|
||||
expect($claims->sub)->toBe('coold-dev')
|
||||
->and($claims->aud)->toBe('coold')
|
||||
->and($claims->caps)->toBe(['containers.list', 'ingress.apply'])
|
||||
->and($claims->exp)->toBeGreaterThan(time());
|
||||
});
|
||||
|
||||
it('mints a host jwt with the dev capability profile by default', function () {
|
||||
[$privateKeyPath, $publicKeyPath] = createFluxJwtKeypair();
|
||||
|
||||
Config::set('flux.jwt_private_key_path', $privateKeyPath);
|
||||
|
||||
$exitCode = Artisan::call('flux:dev', [
|
||||
'host_id' => 'coold-dev',
|
||||
'--ttl' => '600',
|
||||
]);
|
||||
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
$token = trim(Artisan::output());
|
||||
$claims = JWT::decode($token, new Key(file_get_contents($publicKeyPath), 'ES256'));
|
||||
|
||||
expect($claims->caps)->toBe(['host-agent:dev']);
|
||||
});
|
||||
|
||||
it('writes the host jwt to an output path with owner-only permissions', function () {
|
||||
[$privateKeyPath] = createFluxJwtKeypair();
|
||||
$outputPath = storage_path('framework/testing/host-jwt');
|
||||
|
||||
Config::set('flux.jwt_private_key_path', $privateKeyPath);
|
||||
|
||||
$exitCode = Artisan::call('flux:dev', [
|
||||
'host_id' => 'coold-dev',
|
||||
'--output' => $outputPath,
|
||||
]);
|
||||
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
expect($outputPath)->toBeFile()
|
||||
->and(substr(sprintf('%o', fileperms($outputPath)), -4))->toBe('0600');
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
function createFluxJwtKeypair(): array
|
||||
{
|
||||
$directory = storage_path('framework/testing/flux-keys-'.bin2hex(random_bytes(4)));
|
||||
mkdir($directory, 0777, true);
|
||||
|
||||
$privateKeyPath = $directory.'/jwt.priv';
|
||||
$publicKeyPath = $directory.'/jwt.pub';
|
||||
|
||||
exec('openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out '.escapeshellarg($privateKeyPath), $output, $exitCode);
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
exec('openssl pkey -in '.escapeshellarg($privateKeyPath).' -pubout -out '.escapeshellarg($publicKeyPath), $output, $exitCode);
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
return [$privateKeyPath, $publicKeyPath];
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\V5\Server\RemoveBootstrapMarker;
|
||||
use App\Models\V5\RevokedAgentToken;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\Support\V5TestSchema;
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
function createRevocationJwtKeypair(): array
|
||||
{
|
||||
$directory = storage_path('framework/testing/revocation-keys-'.bin2hex(random_bytes(4)));
|
||||
mkdir($directory, 0777, true);
|
||||
|
||||
$privateKeyPath = $directory.'/jwt.priv';
|
||||
$publicKeyPath = $directory.'/jwt.pub';
|
||||
|
||||
exec('openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out '.escapeshellarg($privateKeyPath), $out, $code);
|
||||
expect($code)->toBe(0);
|
||||
chmod($privateKeyPath, 0600);
|
||||
|
||||
exec('openssl pkey -in '.escapeshellarg($privateKeyPath).' -pubout -out '.escapeshellarg($publicKeyPath), $out, $code);
|
||||
expect($code)->toBe(0);
|
||||
|
||||
return [$privateKeyPath, $publicKeyPath];
|
||||
}
|
||||
|
||||
function createRevocationServer(): V5Server
|
||||
{
|
||||
return V5Server::query()->create([
|
||||
'team_id' => 1,
|
||||
'created_by_user_id' => 1,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Config::set('broadcasting.default', 'log');
|
||||
Config::set('cache.default', 'array');
|
||||
|
||||
Schema::dropIfExists('v5_revoked_agent_tokens');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
V5TestSchema::createServersTable();
|
||||
V5TestSchema::createRevokedAgentTokensTable();
|
||||
|
||||
[$this->privateKeyPath, $this->publicKeyPath] = createRevocationJwtKeypair();
|
||||
Config::set('flux.jwt_private_key_path', $this->privateKeyPath);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Schema::dropIfExists('v5_revoked_agent_tokens');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
});
|
||||
|
||||
it('persists the issued jti on the server and reports it as not revoked', function () {
|
||||
$server = createRevocationServer();
|
||||
|
||||
$token = app(AgentTokenIssuer::class)->issueForServer($server);
|
||||
$claims = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'));
|
||||
|
||||
expect($server->fresh()->agent_token_jti)->toBe($claims->jti)
|
||||
->and(app(AgentTokenIssuer::class)->isRevoked($claims->jti))->toBeFalse();
|
||||
});
|
||||
|
||||
it('revokes the currently-issued token and clears the server jti', function () {
|
||||
$issuer = app(AgentTokenIssuer::class);
|
||||
$server = createRevocationServer();
|
||||
|
||||
$token = $issuer->issueForServer($server);
|
||||
$jti = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'))->jti;
|
||||
|
||||
$issuer->revoke($server->fresh());
|
||||
|
||||
expect($issuer->isRevoked($jti))->toBeTrue()
|
||||
->and(RevokedAgentToken::query()->where('jti', $jti)->where('server_id', $server->id)->exists())->toBeTrue()
|
||||
->and($server->fresh()->agent_token_jti)->toBeNull();
|
||||
});
|
||||
|
||||
it('is idempotent when revoking a server with no issued token', function () {
|
||||
$server = createRevocationServer();
|
||||
|
||||
app(AgentTokenIssuer::class)->revoke($server);
|
||||
|
||||
expect(RevokedAgentToken::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('pushes the revocation to flux with the jti and expires_at', function () {
|
||||
$issuer = app(AgentTokenIssuer::class);
|
||||
$server = createRevocationServer();
|
||||
|
||||
$token = $issuer->issueForServer($server);
|
||||
$server = $server->fresh();
|
||||
$jti = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'))->jti;
|
||||
$expiresAtUnix = $server->agent_token_expires_at->getTimestamp();
|
||||
|
||||
$flux = Mockery::mock(FluxClient::class);
|
||||
$flux->shouldReceive('revokeToken')->once()->with($jti, $expiresAtUnix);
|
||||
app()->instance(FluxClient::class, $flux);
|
||||
|
||||
$issuer->revoke($server);
|
||||
|
||||
expect($issuer->isRevoked($jti))->toBeTrue()
|
||||
->and(RevokedAgentToken::query()->where('jti', $jti)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not throw and keeps the local revocation record when the flux push fails', function () {
|
||||
$issuer = app(AgentTokenIssuer::class);
|
||||
$server = createRevocationServer();
|
||||
|
||||
$token = $issuer->issueForServer($server);
|
||||
$jti = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'))->jti;
|
||||
|
||||
$flux = Mockery::mock(FluxClient::class);
|
||||
$flux->shouldReceive('revokeToken')->once()->andThrow(new RuntimeException('flux socket unreachable'));
|
||||
app()->instance(FluxClient::class, $flux);
|
||||
|
||||
// Flux is down: revoke must not throw, and the local record still stands.
|
||||
$issuer->revoke($server->fresh());
|
||||
|
||||
expect($issuer->isRevoked($jti))->toBeTrue()
|
||||
->and(RevokedAgentToken::query()->where('jti', $jti)->exists())->toBeTrue()
|
||||
->and($server->fresh()->agent_token_jti)->toBeNull();
|
||||
});
|
||||
|
||||
it('revokeForServer revokes the token and pushes to flux like revoke', function () {
|
||||
$issuer = app(AgentTokenIssuer::class);
|
||||
$server = createRevocationServer();
|
||||
|
||||
$token = $issuer->issueForServer($server);
|
||||
$jti = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'))->jti;
|
||||
|
||||
$flux = Mockery::mock(FluxClient::class);
|
||||
$flux->shouldReceive('revokeToken')->once()->with($jti, Mockery::type('int'));
|
||||
app()->instance(FluxClient::class, $flux);
|
||||
|
||||
$issuer->revokeForServer($server->fresh());
|
||||
|
||||
expect($issuer->isRevoked($jti))->toBeTrue()
|
||||
->and($server->fresh()->agent_token_jti)->toBeNull();
|
||||
});
|
||||
|
||||
it('revokes the host token when the bootstrap marker is removed', function () {
|
||||
$issuer = app(AgentTokenIssuer::class);
|
||||
$server = createRevocationServer();
|
||||
|
||||
$token = $issuer->issueForServer($server);
|
||||
$jti = JWT::decode($token, new Key(file_get_contents($this->publicKeyPath), 'ES256'))->jti;
|
||||
|
||||
// The server has no private key, so the SSH cleanup returns false, but the
|
||||
// revocation (a pure DB write) still runs first.
|
||||
$result = RemoveBootstrapMarker::run($server->fresh());
|
||||
|
||||
expect($result)->toBeFalse()
|
||||
->and($issuer->isRevoked($jti))->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\V5RotateAgentTokenJob;
|
||||
use App\Jobs\V5RotateAgentTokensJob;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
function createRotationJwtKeypair(): array
|
||||
{
|
||||
$directory = storage_path('framework/testing/rotation-keys-'.bin2hex(random_bytes(4)));
|
||||
mkdir($directory, 0777, true);
|
||||
|
||||
$privateKeyPath = $directory.'/jwt.priv';
|
||||
$publicKeyPath = $directory.'/jwt.pub';
|
||||
|
||||
exec('openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out '.escapeshellarg($privateKeyPath), $out, $code);
|
||||
expect($code)->toBe(0);
|
||||
chmod($privateKeyPath, 0600);
|
||||
|
||||
exec('openssl pkey -in '.escapeshellarg($privateKeyPath).' -pubout -out '.escapeshellarg($publicKeyPath), $out, $code);
|
||||
expect($code)->toBe(0);
|
||||
|
||||
return [$privateKeyPath, $publicKeyPath];
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$this->user, $this->team] = createV5UserWithTeam();
|
||||
|
||||
[$this->privateKeyPath, $this->publicKeyPath] = createRotationJwtKeypair();
|
||||
Config::set('flux.jwt_private_key_path', $this->privateKeyPath);
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
function createRotationServer(array $attributes = []): V5Server
|
||||
{
|
||||
static $counter = 0;
|
||||
$counter++;
|
||||
|
||||
return V5Server::query()->create([
|
||||
'team_id' => test()->team->id,
|
||||
'created_by_user_id' => test()->user->id,
|
||||
'name' => 'edge-'.$counter,
|
||||
'host' => '203.0.113.'.$counter,
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => ['coold'],
|
||||
'wireguard_management_ip' => '100.64.0.'.$counter,
|
||||
'last_bootstrapped_at' => now(),
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
it('issueForServer persists agent_token_expires_at approximately now plus the ttl', function () {
|
||||
Config::set('flux.host_token_ttl', 3600);
|
||||
|
||||
$server = createRotationServer();
|
||||
|
||||
app(AgentTokenIssuer::class)->issueForServer($server);
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->agent_token_jti)->toBeString()->not->toBe('')
|
||||
->and($server->agent_token_expires_at)->not->toBeNull()
|
||||
->and($server->agent_token_expires_at->timestamp)
|
||||
->toBeGreaterThanOrEqual(now()->addSeconds(3600)->subSeconds(30)->timestamp)
|
||||
->toBeLessThanOrEqual(now()->addSeconds(3600)->addSeconds(30)->timestamp);
|
||||
});
|
||||
|
||||
it('dispatches rotation only for eligible servers whose token is missing or near expiry', function () {
|
||||
Config::set('flux.host_token_refresh_threshold', 43200);
|
||||
Queue::fake();
|
||||
|
||||
$neverIssued = createRotationServer(['agent_token_expires_at' => null]);
|
||||
$expiringSoon = createRotationServer(['agent_token_expires_at' => now()->addHours(6)]);
|
||||
$stillFresh = createRotationServer(['agent_token_expires_at' => now()->addHours(20)]);
|
||||
$notInstalled = createRotationServer(['status' => 'added', 'agent_token_expires_at' => null]);
|
||||
$withoutCoold = createRotationServer(['capabilities' => [], 'agent_token_expires_at' => null]);
|
||||
$notBootstrapped = createRotationServer(['last_bootstrapped_at' => null, 'agent_token_expires_at' => null]);
|
||||
|
||||
(new V5RotateAgentTokensJob)->handle();
|
||||
|
||||
Queue::assertPushed(V5RotateAgentTokenJob::class, 2);
|
||||
Queue::assertPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $neverIssued->id);
|
||||
Queue::assertPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $expiringSoon->id);
|
||||
Queue::assertNotPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $stillFresh->id);
|
||||
Queue::assertNotPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $notInstalled->id);
|
||||
Queue::assertNotPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $withoutCoold->id);
|
||||
Queue::assertNotPushed(V5RotateAgentTokenJob::class, fn (V5RotateAgentTokenJob $job) => $job->serverId === $notBootstrapped->id);
|
||||
});
|
||||
|
||||
it('delivers the fresh token over RPC and never touches SSH on RPC success', function () {
|
||||
Config::set('flux.host_token_ttl', 86400);
|
||||
Process::fake();
|
||||
Log::spy();
|
||||
|
||||
$server = createRotationServer([
|
||||
'agent_token_jti' => 'old-jti',
|
||||
'agent_token_expires_at' => now()->addHour(),
|
||||
]);
|
||||
$privateKey = createV5PrivateKey($server->team, 'Rotation Key');
|
||||
$server->update(['private_key_id' => $privateKey->id]);
|
||||
|
||||
$flux = Mockery::mock(FluxClient::class);
|
||||
$flux->shouldReceive('pushHostToken')
|
||||
->once()
|
||||
->with($server->fluxHostId(), Mockery::type('string'));
|
||||
app()->instance(FluxClient::class, $flux);
|
||||
|
||||
(new V5RotateAgentTokenJob($server->id))->handle();
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->agent_token_jti)->not->toBe('old-jti')->toBeString()->not->toBe('')
|
||||
->and($server->agent_token_expires_at->timestamp)
|
||||
->toBeGreaterThan(now()->addHours(20)->timestamp);
|
||||
|
||||
Process::assertNothingRan();
|
||||
Log::shouldHaveReceived('debug')->withArgs(
|
||||
fn (string $message, array $context = []) => ($context['delivery'] ?? null) === 'rpc'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the SSH push when RPC delivery fails and advances jti on SSH success', function () {
|
||||
Config::set('flux.host_token_ttl', 86400);
|
||||
Process::fake(['*' => Process::result(output: '')]);
|
||||
Log::spy();
|
||||
|
||||
$server = createRotationServer([
|
||||
'agent_token_jti' => 'old-jti',
|
||||
'agent_token_expires_at' => now()->addHour(),
|
||||
]);
|
||||
$privateKey = createV5PrivateKey($server->team, 'Rotation Key');
|
||||
$server->update(['private_key_id' => $privateKey->id]);
|
||||
|
||||
$flux = Mockery::mock(FluxClient::class);
|
||||
$flux->shouldReceive('pushHostToken')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('host is not connected'));
|
||||
app()->instance(FluxClient::class, $flux);
|
||||
|
||||
(new V5RotateAgentTokenJob($server->id))->handle();
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->agent_token_jti)->not->toBe('old-jti')->toBeString()->not->toBe('')
|
||||
->and($server->agent_token_expires_at->timestamp)
|
||||
->toBeGreaterThan(now()->addHours(20)->timestamp);
|
||||
|
||||
Process::assertRan(function ($process): bool {
|
||||
$command = $process->command;
|
||||
|
||||
if (! is_array($command) || ($command[0] ?? null) !== 'ssh') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$script = end($command);
|
||||
|
||||
return str_contains($script, '/etc/coolify/host-jwt')
|
||||
&& str_contains($script, 'chmod 600 /etc/coolify/host-jwt')
|
||||
&& str_contains($script, 'tee /etc/coolify/host-jwt');
|
||||
});
|
||||
Log::shouldHaveReceived('debug')->withArgs(
|
||||
fn (string $message, array $context = []) => ($context['delivery'] ?? null) === 'ssh'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves jti and expires_at unchanged when both RPC and SSH delivery fail', function () {
|
||||
Config::set('flux.host_token_ttl', 86400);
|
||||
Process::fake(['*' => Process::result(errorOutput: 'connection refused', exitCode: 255)]);
|
||||
|
||||
$expiresAt = now()->addHour();
|
||||
$server = createRotationServer([
|
||||
'agent_token_jti' => 'old-jti',
|
||||
'agent_token_expires_at' => $expiresAt,
|
||||
]);
|
||||
$privateKey = createV5PrivateKey($server->team, 'Rotation Key');
|
||||
$server->update(['private_key_id' => $privateKey->id]);
|
||||
|
||||
$flux = Mockery::mock(FluxClient::class);
|
||||
$flux->shouldReceive('pushHostToken')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('host is not connected'));
|
||||
app()->instance(FluxClient::class, $flux);
|
||||
|
||||
(new V5RotateAgentTokenJob($server->id))->handle();
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->agent_token_jti)->toBe('old-jti')
|
||||
->and($server->agent_token_expires_at->timestamp)->toBe($expiresAt->timestamp);
|
||||
});
|
||||
|
||||
it('pushHostToken dispatches the host.jwt.set command with the jwt for the target host', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
$responseBody = json_encode([
|
||||
'request_id' => 'test-request',
|
||||
'status' => 'ok',
|
||||
'data' => [],
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
$captured = withCapturingFluxSocket(
|
||||
"HTTP/1.1 200 OK\r\n".
|
||||
"Content-Type: application/json\r\n".
|
||||
'Content-Length: '.strlen($responseBody)."\r\n".
|
||||
"\r\n".
|
||||
$responseBody,
|
||||
function (): void {
|
||||
(new FluxClient)->pushHostToken('100.64.0.42', 'the-new-host-jwt');
|
||||
}
|
||||
);
|
||||
|
||||
$position = strpos($captured, "\r\n\r\n");
|
||||
$requestBody = $position === false ? '' : substr($captured, $position + 4);
|
||||
$decoded = json_decode($requestBody, true);
|
||||
|
||||
expect($decoded)->toBeArray()
|
||||
->and($decoded['host_id'])->toBe('100.64.0.42')
|
||||
->and($decoded['command'])->toBe(['type' => 'host.jwt.set', 'jwt' => 'the-new-host-jwt']);
|
||||
});
|
||||
|
||||
it('revokeToken posts the jti and expires_at to the flux revocation endpoint', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
$responseBody = json_encode(['revoked' => 'the-jti'], JSON_THROW_ON_ERROR);
|
||||
|
||||
$captured = withCapturingFluxSocket(
|
||||
"HTTP/1.1 200 OK\r\n".
|
||||
"Content-Type: application/json\r\n".
|
||||
'Content-Length: '.strlen($responseBody)."\r\n".
|
||||
"\r\n".
|
||||
$responseBody,
|
||||
function (): void {
|
||||
(new FluxClient)->revokeToken('the-jti', 1893456000);
|
||||
}
|
||||
);
|
||||
|
||||
$requestLine = strtok($captured, "\r\n");
|
||||
$position = strpos($captured, "\r\n\r\n");
|
||||
$requestBody = $position === false ? '' : substr($captured, $position + 4);
|
||||
$decoded = json_decode($requestBody, true);
|
||||
|
||||
expect($requestLine)->toBe('POST /v1/tokens/revoke HTTP/1.1')
|
||||
->and($decoded)->toBe(['jti' => 'the-jti', 'expires_at' => 1893456000]);
|
||||
});
|
||||
|
||||
it('revokeToken omits expires_at from the body when it is null', function () {
|
||||
if (! function_exists('pcntl_fork')) {
|
||||
$this->markTestSkipped('pcntl is required to fake a Flux Unix socket.');
|
||||
}
|
||||
|
||||
$responseBody = json_encode(['revoked' => 'the-jti'], JSON_THROW_ON_ERROR);
|
||||
|
||||
$captured = withCapturingFluxSocket(
|
||||
"HTTP/1.1 200 OK\r\n".
|
||||
"Content-Type: application/json\r\n".
|
||||
'Content-Length: '.strlen($responseBody)."\r\n".
|
||||
"\r\n".
|
||||
$responseBody,
|
||||
function (): void {
|
||||
(new FluxClient)->revokeToken('the-jti');
|
||||
}
|
||||
);
|
||||
|
||||
$position = strpos($captured, "\r\n\r\n");
|
||||
$requestBody = $position === false ? '' : substr($captured, $position + 4);
|
||||
|
||||
expect(json_decode($requestBody, true))->toBe(['jti' => 'the-jti']);
|
||||
});
|
||||
|
||||
it('runs the rotation jobs on the dedicated v5-reconcile queue', function () {
|
||||
expect((new V5RotateAgentTokensJob)->queue)->toBe('v5-reconcile')
|
||||
->and((new V5RotateAgentTokenJob(1))->queue)->toBe('v5-reconcile');
|
||||
});
|
||||
|
||||
/**
|
||||
* Runs $callback against a fake Flux Unix socket that records the full HTTP
|
||||
* request (headers + body) it receives, then returns the captured raw request
|
||||
* so the wire command can be asserted.
|
||||
*/
|
||||
function withCapturingFluxSocket(string $response, Closure $callback): string
|
||||
{
|
||||
$directory = storage_path('framework/testing');
|
||||
|
||||
if (! is_dir($directory)) {
|
||||
mkdir($directory, 0777, true);
|
||||
}
|
||||
|
||||
$socketPath = $directory.'/flux-'.bin2hex(random_bytes(8)).'.sock';
|
||||
$capturePath = $directory.'/flux-capture-'.bin2hex(random_bytes(8)).'.txt';
|
||||
$socketServer = stream_socket_server("unix://{$socketPath}", $errorCode, $errorMessage);
|
||||
|
||||
expect($socketServer)->not->toBeFalse("Could not create fake Flux socket: {$errorMessage} ({$errorCode})");
|
||||
|
||||
$pid = pcntl_fork();
|
||||
|
||||
if ($pid === 0) {
|
||||
$connection = stream_socket_accept($socketServer, 5);
|
||||
|
||||
if ($connection !== false) {
|
||||
$request = '';
|
||||
|
||||
while (! str_contains($request, "\r\n\r\n") && ! feof($connection)) {
|
||||
$request .= fread($connection, 8192);
|
||||
}
|
||||
|
||||
if (preg_match('/Content-Length:\s*(\d+)/i', $request, $matches) === 1) {
|
||||
$headerEnd = strpos($request, "\r\n\r\n") + 4;
|
||||
$needed = (int) $matches[1];
|
||||
|
||||
while (strlen($request) - $headerEnd < $needed && ! feof($connection)) {
|
||||
$chunk = fread($connection, 8192);
|
||||
|
||||
if ($chunk === '' || $chunk === false) {
|
||||
break;
|
||||
}
|
||||
|
||||
$request .= $chunk;
|
||||
}
|
||||
}
|
||||
|
||||
file_put_contents($capturePath, $request);
|
||||
fwrite($connection, $response);
|
||||
fclose($connection);
|
||||
}
|
||||
|
||||
fclose($socketServer);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fclose($socketServer);
|
||||
Config::set('flux.unix_socket_path', $socketPath);
|
||||
Config::set('flux.connection_timeout_seconds', 1.0);
|
||||
Config::set('flux.dispatch_timeout_seconds', 1.0);
|
||||
|
||||
try {
|
||||
$callback();
|
||||
} finally {
|
||||
pcntl_waitpid($pid, $status);
|
||||
}
|
||||
|
||||
$captured = is_file($capturePath) ? (string) file_get_contents($capturePath) : '';
|
||||
|
||||
@unlink($socketPath);
|
||||
@unlink($capturePath);
|
||||
|
||||
return $captured;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
it('renders the Coolify dashboard icon smaller than the nav height', function () {
|
||||
$navbar = file_get_contents(resource_path('js/v5/components/app-navbar.tsx'));
|
||||
|
||||
expect($navbar)->toContain('<img src="/coolify-logo.svg" alt="Coolify" className="size-6" />')
|
||||
->and($navbar)->not->toContain('<img src="/coolify-logo.svg" alt="Coolify" className="size-8" />');
|
||||
});
|
||||
|
||||
it('shows keyboard focus on ghost select dropdown triggers', function () {
|
||||
$selectComponent = file_get_contents(resource_path('js/v5/components/ui/select.tsx'));
|
||||
|
||||
expect($selectComponent)
|
||||
->toContain("variant === 'ghost'")
|
||||
->toContain('focus-visible:border-ring')
|
||||
->not->toContain('focus-visible:border-transparent');
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
use App\Events\V5CanvasResourceUpdated;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Jobs\V5BootstrapServerJob;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Cluster;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\Support\V5TestSchema;
|
||||
|
||||
beforeEach(function () {
|
||||
Config::set('broadcasting.default', 'log');
|
||||
Config::set('cache.default', 'array');
|
||||
|
||||
Schema::dropIfExists('v5_servers');
|
||||
Schema::dropIfExists('v5_clusters');
|
||||
Schema::dropIfExists('private_keys');
|
||||
Schema::dropIfExists('team_user');
|
||||
Schema::dropIfExists('teams');
|
||||
Schema::dropIfExists('users');
|
||||
});
|
||||
|
||||
function createBootstrapPreflightTables(): void
|
||||
{
|
||||
Schema::create('users', function ($table) {
|
||||
$table->id();
|
||||
$table->string('name')->default('Anonymous');
|
||||
$table->string('email');
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('teams', function ($table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('description')->nullable();
|
||||
$table->boolean('personal_team')->default(false);
|
||||
$table->boolean('show_boarding')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('team_user', function ($table) {
|
||||
$table->id();
|
||||
$table->foreignId('team_id');
|
||||
$table->foreignId('user_id');
|
||||
$table->string('role')->default('member');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['team_id', 'user_id']);
|
||||
});
|
||||
|
||||
Schema::create('private_keys', function ($table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->string('name');
|
||||
$table->string('description')->nullable();
|
||||
$table->longText('private_key');
|
||||
$table->string('fingerprint')->nullable();
|
||||
$table->boolean('is_git_related')->default(false);
|
||||
$table->foreignId('team_id');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
V5TestSchema::createClustersTable();
|
||||
V5TestSchema::createServersTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: User, 1: Team}
|
||||
*/
|
||||
function createBootstrapPreflightUserWithTeam(): array
|
||||
{
|
||||
$user = User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Margaret Hamilton',
|
||||
'email' => 'margaret@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
$team = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'V5 Tooling Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
|
||||
return [$user, $team];
|
||||
}
|
||||
|
||||
function createBootstrapPreflightPrivateKey(Team $team): PrivateKey
|
||||
{
|
||||
return PrivateKey::withoutEvents(fn () => PrivateKey::query()->forceCreate([
|
||||
'uuid' => 'preflight-key-uuid',
|
||||
'name' => 'Preflight SSH Key',
|
||||
'description' => null,
|
||||
'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n",
|
||||
'fingerprint' => 'preflight-key',
|
||||
'is_git_related' => false,
|
||||
'team_id' => $team->id,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: User, 1: Team, 2: Cluster, 3: V5Server}
|
||||
*/
|
||||
function createBootstrapPreflightClusterWithServer(array $clusterAttributes = [], array $serverAttributes = []): array
|
||||
{
|
||||
[$user, $team] = createBootstrapPreflightUserWithTeam();
|
||||
$privateKey = createBootstrapPreflightPrivateKey($team);
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
...$clusterAttributes,
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'added',
|
||||
...$serverAttributes,
|
||||
]);
|
||||
|
||||
return [$user, $team, $cluster, $server];
|
||||
}
|
||||
|
||||
it('rejects bootstrap when flux url is not configured', function () {
|
||||
createBootstrapPreflightTables();
|
||||
Config::set('coold.flux_url', '');
|
||||
|
||||
[$user, $team, $cluster, $server] = createBootstrapPreflightClusterWithServer();
|
||||
|
||||
Queue::fake();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap")
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('message', 'COOLIFY_COOLD_FLUX_URL is not configured, so bootstrapped servers cannot be enrolled into Flux. Set it and retry the bootstrap.');
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
|
||||
// The claim must not have been written either.
|
||||
expect($server->refresh()->last_bootstrap_status)->toBeNull();
|
||||
});
|
||||
|
||||
it('queues the bootstrap when the flux url is configured', function () {
|
||||
createBootstrapPreflightTables();
|
||||
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
|
||||
|
||||
[$user, $team, $cluster, $server] = createBootstrapPreflightClusterWithServer();
|
||||
|
||||
Queue::fake();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson("/v5/clusters/{$cluster->uuid}/servers/{$server->uuid}/bootstrap")
|
||||
->assertAccepted()
|
||||
->assertJsonPath('message', 'Bootstrap queued.');
|
||||
|
||||
Queue::assertPushed(V5BootstrapServerJob::class);
|
||||
});
|
||||
|
||||
it('writes the marker with the same coold version that is persisted on the server row', function () {
|
||||
createBootstrapPreflightTables();
|
||||
Config::set('coold.coolify_cli_bin', '/tmp/coolify');
|
||||
Config::set('coold.flux_url', 'http://coolify.example.com:6443');
|
||||
|
||||
[, , $cluster, $server] = createBootstrapPreflightClusterWithServer([
|
||||
'namespaces' => ['default'],
|
||||
'coold_version' => 'v0.9.9',
|
||||
], [
|
||||
'last_bootstrap_status' => 'queued',
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
$issuer = Mockery::mock(AgentTokenIssuer::class);
|
||||
$issuer->shouldReceive('issueForServer')->andReturn('signed-host-jwt');
|
||||
app()->instance(AgentTokenIssuer::class, $issuer);
|
||||
|
||||
// The CLI reports a newer coold version than the cluster asked for; the
|
||||
// marker and the database row must both use the reported version.
|
||||
Process::fake([
|
||||
'*' => Process::sequence()
|
||||
->push(Process::result(output: ''))
|
||||
->push(Process::result(output: json_encode([
|
||||
'results' => [],
|
||||
'coold_version' => 'v1.2.3',
|
||||
'verified' => [[
|
||||
'host' => "v5-server-{$server->uuid}",
|
||||
'wireguard_ip' => '100.64.0.1',
|
||||
'peer_count' => 0,
|
||||
'status' => 'ok',
|
||||
]],
|
||||
], JSON_THROW_ON_ERROR)."\n"))
|
||||
->push(Process::result(output: "public-key\n"))
|
||||
->push(Process::result(output: "default=10.210.0.0/24\n"))
|
||||
->push(Process::result(output: "marker written\n"))
|
||||
->push(Process::result(output: "coold restarted\n")),
|
||||
]);
|
||||
|
||||
Event::fake([V5ClusterUpdated::class, V5CanvasResourceUpdated::class]);
|
||||
|
||||
(new V5BootstrapServerJob($cluster->id, $server->id))->handle();
|
||||
|
||||
$server->refresh();
|
||||
|
||||
expect($server->last_bootstrap_status)->toBe('succeeded')
|
||||
->and($server->status)->toBe('installed')
|
||||
->and($server->coold_version)->toBe('v1.2.3');
|
||||
|
||||
$markerPayload = null;
|
||||
|
||||
Process::assertRan(function ($process) use (&$markerPayload): bool {
|
||||
$command = implode(' ', array_map(fn ($part) => is_string($part) ? $part : json_encode($part), $process->command));
|
||||
|
||||
if (! str_contains($command, 'v5-node.json') || ! str_contains($command, 'base64 -d')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (preg_match("/payload='([^']+)'/", $command, $matches) === 1) {
|
||||
$markerPayload = json_decode(base64_decode($matches[1], true), true);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
expect($markerPayload)->toBeArray()
|
||||
->and($markerPayload['coold_version'])->toBe('v1.2.3')
|
||||
->and($markerPayload['coold_version'])->toBe($server->coold_version)
|
||||
->and($markerPayload['server_uuid'])->toBe($server->uuid)
|
||||
->and($markerPayload['wireguard_management_ip'])->toBe('100.64.0.1');
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
Config::set('broadcasting.default', 'pusher');
|
||||
Config::set('broadcasting.connections.pusher.key', 'test-key');
|
||||
Config::set('broadcasting.connections.pusher.secret', 'test-secret');
|
||||
Config::set('broadcasting.connections.pusher.app_id', 'test-app-id');
|
||||
Config::set('broadcasting.connections.pusher.options', [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 6001,
|
||||
'scheme' => 'http',
|
||||
'useTLS' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('authorizes team members on their private team channel', function () {
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->postJson('/broadcasting/auth', [
|
||||
'channel_name' => "private-team.{$team->id}",
|
||||
'socket_id' => '1234.5678',
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonStructure(['auth']);
|
||||
});
|
||||
|
||||
it('rejects users outside the team on the private team channel', function () {
|
||||
[, $team] = createV5UserWithTeam();
|
||||
|
||||
$outsider = User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Outsider',
|
||||
'email' => 'outsider@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'Another Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
$outsider->teams()->attach($otherTeam, ['role' => 'owner']);
|
||||
|
||||
$this
|
||||
->actingAs($outsider)
|
||||
->postJson('/broadcasting/auth', [
|
||||
'channel_name' => "private-team.{$team->id}",
|
||||
'socket_id' => '1234.5678',
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('rejects guests on the private team channel', function () {
|
||||
[, $team] = createV5UserWithTeam();
|
||||
|
||||
$this
|
||||
->postJson('/broadcasting/auth', [
|
||||
'channel_name' => "private-team.{$team->id}",
|
||||
'socket_id' => '1234.5678',
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
it('uses yellow focus borders for normal v5 controls and purple focus borders for coolify buttons', function () {
|
||||
$v5Css = file_get_contents(resource_path('css/v5/app.css'));
|
||||
$buttonComponent = file_get_contents(resource_path('js/v5/components/ui/button.tsx'));
|
||||
$inputComponent = file_get_contents(resource_path('js/v5/components/ui/input.tsx'));
|
||||
$selectComponent = file_get_contents(resource_path('js/v5/components/ui/select.tsx'));
|
||||
$textareaComponent = file_get_contents(resource_path('js/v5/components/ui/textarea.tsx'));
|
||||
|
||||
expect($v5Css)
|
||||
->toContain(':focus-visible')
|
||||
->toContain('ring-2 ring-ring ring-offset-2')
|
||||
->toContain('--tw-ring-offset-color: var(--background)')
|
||||
->and($buttonComponent)
|
||||
->toContain('focus-visible:border-ring')
|
||||
->toContain('focus-visible:border-coollabs-100')
|
||||
->toContain('dark:focus-visible:border-coollabs-100')
|
||||
->not->toContain('dark:focus-visible:border-coollabs-50')
|
||||
->not->toContain('focus-visible:ring-')
|
||||
->not->toContain('focus-visible:border-destructive')
|
||||
->and($inputComponent)
|
||||
->toContain('focus:border-ring')
|
||||
->toContain('focus:ring-0')
|
||||
->not->toContain('focus:ring-1')
|
||||
->and($selectComponent)
|
||||
->toContain('focus-visible:border-ring')
|
||||
->not->toContain('focus-visible:ring-')
|
||||
->and($textareaComponent)
|
||||
->toContain('focus:border-ring')
|
||||
->toContain('focus:ring-0')
|
||||
->not->toContain('focus:ring-1');
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
it('does not render server capability summaries on the cluster page', function () {
|
||||
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
|
||||
|
||||
expect($clustersPage)
|
||||
->not->toContain('Capabilities:')
|
||||
->not->toContain('normalizeCapabilities');
|
||||
});
|
||||
|
||||
it('shows server update error messages returned by the v5 api', function () {
|
||||
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
|
||||
|
||||
expect($clustersPage)
|
||||
->toContain("payload?.message ?? 'Unable to update this server. Please try again.'");
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Cluster;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
});
|
||||
|
||||
it('returns fresh v5 cluster bootstrap state for realtime fallback refreshes', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'added',
|
||||
'builder_enabled' => false,
|
||||
'builder_capacity' => 0,
|
||||
'last_bootstrap_action' => 'bootstrap',
|
||||
'last_bootstrap_status' => 'running',
|
||||
'last_bootstrap_output' => 'Starting Coolify CLI bootstrap for prod-01...',
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->getJson("/v5/clusters/{$cluster->uuid}")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('cluster.id', $cluster->uuid)
|
||||
->assertJsonPath('cluster.servers.0.id', $server->uuid)
|
||||
->assertJsonPath('cluster.servers.0.lastBootstrapStatus', 'running')
|
||||
->assertJsonPath('cluster.servers.0.lastBootstrapOutput', 'Starting Coolify CLI bootstrap for prod-01...');
|
||||
});
|
||||
|
||||
it('shows v5 clusters with their servers on the cluster page', function () {
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Lima Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Development-Lima',
|
||||
'description' => 'Local Lima development cluster managed by scripts/dev.sh.',
|
||||
]);
|
||||
V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'coold-dev',
|
||||
'host' => 'lima-coold-dev',
|
||||
'ssh_user' => 'developer',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => ['builder'],
|
||||
'builder_enabled' => true,
|
||||
'builder_capacity' => 2,
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get('/v5/clusters')
|
||||
->assertSuccessful()
|
||||
->assertSee('Clusters', false)
|
||||
->assertSee('"clusters":[', false)
|
||||
->assertSee('"privateKeys":[', false)
|
||||
->assertSee('"name":"Lima Key"', false)
|
||||
->assertSee('"name":"Development-Lima"', false)
|
||||
->assertSee('"serversCount":1', false)
|
||||
->assertSee('"name":"coold-dev"', false)
|
||||
->assertSee('"host":"lima-coold-dev"', false)
|
||||
->assertDontSee('"sshUser"', false)
|
||||
->assertDontSee('"sshPort"', false)
|
||||
->assertSee('"builderEnabled":true', false)
|
||||
->assertSee('"builderCapacity":2', false)
|
||||
->assertSee('"wireguardInterface":"wg0"', false)
|
||||
->assertSee('"wireguardManagementPool":"100.64.0.0\\/16"', false)
|
||||
->assertSee('"containerNetworkPool":"10.210.0.0\\/16"', false)
|
||||
->assertSee('"namespaces":["default"]', false)
|
||||
->assertSee('"defaultDenyContainers":true', false)
|
||||
->assertSee('"cooldVersion":"nightly"', false)
|
||||
->assertSee('"corrosionVersion":"v1.0.0"', false)
|
||||
->assertSee('"builderCpuQuota":"200%"', false)
|
||||
->assertSee('"builderMemoryMax":"2G"', false)
|
||||
->assertSee('"builderTimeoutSecs":1800', false)
|
||||
->assertSee('"lastCliStatus":null', false)
|
||||
->assertSee('"privateKeyName":"Lima Key"', false)
|
||||
->assertSee('"nodeAddress":null', false)
|
||||
->assertSee('"wireguardManagementIp":null', false)
|
||||
->assertSee('"containerSubnets":[]', false)
|
||||
->assertSee('"lastBootstrappedAt":"', false);
|
||||
});
|
||||
|
||||
it('creates a v5 cluster for the current team', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/clusters', [
|
||||
'name' => 'Production Mesh',
|
||||
'description' => 'Primary production cluster.',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('cluster.name', 'Production Mesh')
|
||||
->assertJsonPath('cluster.description', 'Primary production cluster.')
|
||||
->assertJsonPath('cluster.wireguardInterface', 'wg0')
|
||||
->assertJsonPath('cluster.wireguardManagementPool', '100.64.0.0/16')
|
||||
->assertJsonPath('cluster.wireguardListenPort', 51820)
|
||||
->assertJsonPath('cluster.containerNetworkPool', '10.210.0.0/16')
|
||||
->assertJsonPath('cluster.containerNetworkPrefix', 24)
|
||||
->assertJsonPath('cluster.namespaces', ['default'])
|
||||
->assertJsonPath('cluster.defaultDenyContainers', true)
|
||||
->assertJsonPath('cluster.cooldVersion', 'nightly')
|
||||
->assertJsonPath('cluster.corrosionVersion', 'v1.0.0')
|
||||
->assertJsonPath('cluster.corrosionGossipPort', 8787)
|
||||
->assertJsonPath('cluster.corrosionApiPort', 8080)
|
||||
->assertJsonPath('cluster.builderEnabled', true)
|
||||
->assertJsonPath('cluster.builderCapacity', 2)
|
||||
->assertJsonPath('cluster.builderCpuQuota', '200%')
|
||||
->assertJsonPath('cluster.builderMemoryMax', '2G')
|
||||
->assertJsonPath('cluster.builderTimeoutSecs', 1800)
|
||||
->assertJsonPath('cluster.lastCliAction', null)
|
||||
->assertJsonPath('cluster.lastCliStatus', null)
|
||||
->assertJsonPath('cluster.lastCliSummary', null)
|
||||
->assertJsonPath('cluster.lastCliRanAt', null)
|
||||
->assertJsonPath('cluster.serversCount', 0)
|
||||
->assertJsonPath('cluster.servers', []);
|
||||
|
||||
expect(Cluster::query()
|
||||
->where('team_id', $team->id)
|
||||
->where('created_by_user_id', $user->id)
|
||||
->where('name', 'Production Mesh')
|
||||
->where('description', 'Primary production cluster.')
|
||||
->where('wireguard_interface', 'wg0')
|
||||
->where('wireguard_management_pool', '100.64.0.0/16')
|
||||
->where('container_network_pool', '10.210.0.0/16')
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates a v5 cluster with advanced cli configuration', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/clusters', [
|
||||
'name' => 'Custom Mesh',
|
||||
'description' => null,
|
||||
'wireguard_interface' => 'wg-prod',
|
||||
'wireguard_management_pool' => '100.65.0.0/16',
|
||||
'wireguard_listen_port' => 51830,
|
||||
'container_network_pool' => '10.211.0.0/16',
|
||||
'container_network_prefix' => 25,
|
||||
'namespaces' => ['default', 'preview'],
|
||||
'default_deny_containers' => false,
|
||||
'coold_version' => 'v0.2.0',
|
||||
'corrosion_version' => 'v1.1.0',
|
||||
'corrosion_gossip_port' => 8788,
|
||||
'corrosion_api_port' => 8081,
|
||||
'builder_enabled' => true,
|
||||
'builder_capacity' => 4,
|
||||
'builder_cpu_quota' => '400%',
|
||||
'builder_memory_max' => '4G',
|
||||
'builder_timeout_secs' => 2400,
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('cluster.wireguardInterface', 'wg-prod')
|
||||
->assertJsonPath('cluster.wireguardManagementPool', '100.65.0.0/16')
|
||||
->assertJsonPath('cluster.wireguardListenPort', 51830)
|
||||
->assertJsonPath('cluster.containerNetworkPool', '10.211.0.0/16')
|
||||
->assertJsonPath('cluster.containerNetworkPrefix', 25)
|
||||
->assertJsonPath('cluster.namespaces', ['default', 'preview'])
|
||||
->assertJsonPath('cluster.defaultDenyContainers', false)
|
||||
->assertJsonPath('cluster.cooldVersion', 'v0.2.0')
|
||||
->assertJsonPath('cluster.corrosionVersion', 'v1.1.0')
|
||||
->assertJsonPath('cluster.builderCapacity', 4)
|
||||
->assertJsonPath('cluster.builderCpuQuota', '400%')
|
||||
->assertJsonPath('cluster.builderMemoryMax', '4G')
|
||||
->assertJsonPath('cluster.builderTimeoutSecs', 2400);
|
||||
});
|
||||
|
||||
it('validates advanced v5 cluster cli configuration', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/clusters', [
|
||||
'name' => 'Broken Mesh',
|
||||
'wireguard_management_pool' => 'not-a-cidr',
|
||||
'container_network_pool' => '10.211.0.0',
|
||||
'wireguard_listen_port' => 70000,
|
||||
'namespaces' => ['Default'],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors([
|
||||
'wireguard_management_pool',
|
||||
'container_network_pool',
|
||||
'wireguard_listen_port',
|
||||
'namespaces.0',
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires positive v5 cluster builder capacity when builders are enabled', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/clusters', [
|
||||
'name' => 'Zero Builder Mesh',
|
||||
'builder_enabled' => true,
|
||||
'builder_capacity' => 0,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['builder_capacity']);
|
||||
});
|
||||
|
||||
it('validates v5 cluster creation input', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/clusters', [
|
||||
'name' => '',
|
||||
'description' => str_repeat('a', 1001),
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['name', 'description']);
|
||||
});
|
||||
|
||||
it('rejects duplicate v5 cluster names in the same team', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/clusters', [
|
||||
'name' => 'Production Mesh',
|
||||
'description' => null,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['name']);
|
||||
});
|
||||
|
||||
it('deletes an empty v5 cluster in the current team', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Empty Mesh',
|
||||
'description' => null,
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->deleteJson('/v5/clusters/'.$cluster->uuid)
|
||||
->assertNoContent();
|
||||
|
||||
expect(Cluster::query()->whereKey($cluster->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not delete a v5 cluster that has servers', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$privateKey = createV5PrivateKey($team, 'Lima Key');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Development-Lima',
|
||||
'description' => null,
|
||||
]);
|
||||
V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'name' => 'coold-dev',
|
||||
'host' => 'lima-coold-dev',
|
||||
'ssh_user' => 'developer',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => ['builder'],
|
||||
'builder_enabled' => true,
|
||||
'builder_capacity' => 2,
|
||||
'last_bootstrapped_at' => now(),
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->deleteJson('/v5/clusters/'.$cluster->uuid)
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('message', 'Only empty clusters can be deleted.');
|
||||
|
||||
expect(Cluster::query()->whereKey($cluster->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not delete a v5 cluster outside the current team', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'Other V5 Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
$otherUser = User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Other User',
|
||||
'email' => 'other-delete@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $otherTeam->id,
|
||||
'created_by_user_id' => $otherUser->id,
|
||||
'name' => 'Other Mesh',
|
||||
'description' => null,
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->deleteJson('/v5/clusters/'.$cluster->uuid)
|
||||
->assertNotFound();
|
||||
|
||||
expect(Cluster::query()->whereKey($cluster->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows the same v5 cluster name in another team without leaking it', function () {
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'Other V5 Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
$otherUser = User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Other User',
|
||||
'email' => 'other@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
Cluster::query()->create([
|
||||
'team_id' => $otherTeam->id,
|
||||
'created_by_user_id' => $otherUser->id,
|
||||
'name' => 'Production Mesh',
|
||||
'description' => 'Other team cluster.',
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/clusters', [
|
||||
'name' => 'Production Mesh',
|
||||
'description' => 'Current team cluster.',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('cluster.description', 'Current team cluster.');
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get('/v5/clusters')
|
||||
->assertSuccessful()
|
||||
->assertSee('Current team cluster.')
|
||||
->assertDontSee('Other team cluster.');
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
it('keeps the cluster selector and add button the same height', function () {
|
||||
$clustersPage = file_get_contents(resource_path('js/v5/Pages/Clusters.tsx'));
|
||||
|
||||
expect($clustersPage)->toContain('<SelectTrigger')
|
||||
->and($clustersPage)->toContain('className="w-full sm:w-72"')
|
||||
->and($clustersPage)->toContain('variant="coolify"')
|
||||
->and($clustersPage)->toContain('size="default"')
|
||||
->and($clustersPage)->not->toContain('variant="coolify"\n size="sm"');
|
||||
});
|
||||
@@ -0,0 +1,514 @@
|
||||
<?php
|
||||
|
||||
use App\Events\V5RealtimeTestEvent;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Application;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
});
|
||||
|
||||
it('serves the v5 inertia shell', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
$this->withoutExceptionHandling();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
$user = User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Ada Lovelace',
|
||||
'email' => 'ada@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
$team = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'V5 Shared Team',
|
||||
'description' => 'Shared team details',
|
||||
'personal_team' => true,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('<html lang="en" class="dark">', false)
|
||||
->assertSee('coolify-logo-dev-transparent.png', false)
|
||||
->assertDontSee('coolify-logo.svg', false)
|
||||
->assertSee('js/echo.js', false)
|
||||
->assertSee('js/pusher.js', false)
|
||||
->assertSee('window.Echo', false)
|
||||
->assertSee('v5-app', false)
|
||||
->assertSee('Dashboard', false)
|
||||
->assertDontSee('Home', false)
|
||||
->assertDontSee('v5-ready', false)
|
||||
->assertDontSee('This page is served from Laravel through Inertia and React')
|
||||
->assertDontSee('Bootstrap server')
|
||||
->assertDontSee('privateKeys', false)
|
||||
->assertSee('Running')
|
||||
->assertSee('Flux is running.')
|
||||
->assertDontSee('"clusters":', false)
|
||||
->assertDontSee('cooldServers', false)
|
||||
->assertDontSee('coold-dev')
|
||||
->assertDontSee('Create cluster')
|
||||
->assertDontSee('Cluster details')
|
||||
->assertDontSee('100.64.0.1')
|
||||
->assertDontSee('Current team')
|
||||
->assertDontSee('Your teams')
|
||||
->assertSee('currentTeam', false)
|
||||
->assertSee('"currentTeam":{"id":'.$team->id, false)
|
||||
->assertDontSee('teams', false)
|
||||
->assertDontSee('V5 Shared Team')
|
||||
->assertDontSee('Shared team details');
|
||||
});
|
||||
|
||||
it('serves v5 dashboard applications as canvas nodes', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
[$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($team, 'Staging Project', 'Staging');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'status' => 'running',
|
||||
'status_message' => 'Container started.',
|
||||
'runtime_container_id' => 'abc123',
|
||||
'mesh_namespace' => 'default',
|
||||
'canvas_x' => 120,
|
||||
'canvas_y' => -80,
|
||||
]);
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $otherProject->id,
|
||||
'environment_id' => $otherEnvironment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'other-nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-other',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession([
|
||||
'currentTeam' => $team,
|
||||
'v5.selectedProjectUuid' => $project->uuid,
|
||||
'v5.selectedEnvironmentUuid' => $environment->uuid,
|
||||
])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('"applications":[', false)
|
||||
->assertSee('"name":"nginx-test"', false)
|
||||
->assertSee('"serverName":"edge-01"', false)
|
||||
->assertSee('"meshNamespace":"default"', false)
|
||||
->assertSee('"meshFqdn":"coolify-v5-nginx-1.default.coolify.internal"', false)
|
||||
->assertSee('"canvasX":120', false)
|
||||
->assertSee('"canvasY":-80', false)
|
||||
->assertDontSee('other-nginx-test', false);
|
||||
});
|
||||
|
||||
it('marks v5 application status as unknown when its server is unreachable', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'unreachable',
|
||||
'last_status_output' => 'coold heartbeat timed out.',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
|
||||
V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'status' => 'running',
|
||||
'status_message' => 'Container started.',
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession([
|
||||
'currentTeam' => $team,
|
||||
'v5.selectedProjectUuid' => $project->uuid,
|
||||
'v5.selectedEnvironmentUuid' => $environment->uuid,
|
||||
])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('"status":"running"', false)
|
||||
->assertSee('"effectiveStatus":"unknown"', false)
|
||||
->assertSee('"effectiveStatusMessage":"coold heartbeat timed out."', false)
|
||||
->assertSee('"serverStatus":"unreachable"', false)
|
||||
->assertSee('"isServerReachable":false', false);
|
||||
});
|
||||
|
||||
it('shows v5 caddy ingress as unreachable when its server is unreachable', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-ingress-01',
|
||||
'host' => '203.0.113.20',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'unreachable',
|
||||
'ingress_status' => 'running',
|
||||
'last_status_output' => 'coold heartbeat timed out.',
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('"caddyIngresses":[', false)
|
||||
->assertSee('"status":"unreachable"', false)
|
||||
->assertSee('"statusMessage":"coold heartbeat timed out."', false);
|
||||
});
|
||||
|
||||
it('serves enabled v5 caddy ingress servers as canvas nodes', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
|
||||
V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-ingress-01',
|
||||
'host' => '203.0.113.20',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'ingress_status' => 'running',
|
||||
'capabilities' => ['ingress'],
|
||||
'canvas_x' => -160,
|
||||
'canvas_y' => 240,
|
||||
]);
|
||||
V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-ingress-02',
|
||||
'host' => '203.0.113.22',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'ingress_status' => 'exited',
|
||||
'capabilities' => ['ingress'],
|
||||
]);
|
||||
V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-worker-01',
|
||||
'host' => '203.0.113.21',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('"caddyIngresses":[', false)
|
||||
->assertSee('"name":"edge-ingress-01"', false)
|
||||
->assertSee('"host":"203.0.113.20"', false)
|
||||
->assertSee('"type":"caddy"', false)
|
||||
->assertSee('"status":"running"', false)
|
||||
->assertSee('"name":"edge-ingress-02"', false)
|
||||
->assertSee('"status":"exited"', false)
|
||||
->assertSee('"canvasX":-160', false)
|
||||
->assertSee('"canvasY":240', false);
|
||||
});
|
||||
|
||||
it('broadcasts manual v5 realtime test events immediately', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
Event::fake([V5RealtimeTestEvent::class]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/realtime-test', [
|
||||
'message' => 'hello socket',
|
||||
])
|
||||
->assertAccepted()
|
||||
->assertJsonPath('message', 'Realtime test event broadcasted.');
|
||||
|
||||
Event::assertDispatched(V5RealtimeTestEvent::class, fn (V5RealtimeTestEvent $event): bool => $event->teamId === $team->id
|
||||
&& $event->message === 'hello socket'
|
||||
&& $event->broadcastAs() === 'v5.realtime.test'
|
||||
&& $event instanceof ShouldBroadcastNow);
|
||||
});
|
||||
|
||||
it('serves the production v5 favicon outside local environments', function () {
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('coolify-logo.svg', false)
|
||||
->assertDontSee('coolify-logo-dev-transparent.png', false);
|
||||
});
|
||||
|
||||
it('shares existing projects and environments with the v5 dashboard page', function () {
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'gravy-truck', 'production');
|
||||
createV5ProjectWithEnvironment($team, 'rocket-bike', 'staging');
|
||||
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'Other V5 Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
[$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($otherTeam, 'secret-saucer', 'private');
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('"projects":[', false)
|
||||
->assertSee('"name":"gravy-truck"', false)
|
||||
->assertSee('"uuid":"'.$project->uuid.'"', false)
|
||||
->assertSee('"environments":[', false)
|
||||
->assertSee('"name":"production"', false)
|
||||
->assertSee('"uuid":"'.$environment->uuid.'"', false)
|
||||
->assertSee('"selectedProjectUuid":"'.$project->uuid.'"', false)
|
||||
->assertSee('"selectedEnvironmentUuid":"'.$environment->uuid.'"', false)
|
||||
->assertDontSee('"id":"'.$project->id.'","uuid":"'.$project->uuid.'"', false)
|
||||
->assertDontSee('"id":"'.$environment->id.'","uuid":"'.$environment->uuid.'"', false)
|
||||
->assertDontSee($otherProject->name)
|
||||
->assertDontSee($otherProject->uuid)
|
||||
->assertDontSee($otherEnvironment->name)
|
||||
->assertDontSee($otherEnvironment->uuid);
|
||||
});
|
||||
|
||||
it('opens a linked v5 application in its project and environment', function () {
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'linked-project', 'production');
|
||||
$application = Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'linked-nginx',
|
||||
'image' => 'nginx:alpine',
|
||||
'container_name' => 'linked-v5-nginx',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get(route('v5.dashboard', [
|
||||
'project' => $project->uuid,
|
||||
'environment' => $environment->uuid,
|
||||
'application' => $application->uuid,
|
||||
]))
|
||||
->assertSuccessful()
|
||||
->assertSee('"selectedProjectUuid":"'.$project->uuid.'"', false)
|
||||
->assertSee('"selectedEnvironmentUuid":"'.$environment->uuid.'"', false)
|
||||
->assertSee('"selectedApplicationUuid":"'.$application->uuid.'"', false);
|
||||
});
|
||||
|
||||
it('persists the selected v5 project and environment in the session', function () {
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
createV5ProjectWithEnvironment($team, 'alpha-project', 'production');
|
||||
[$selectedProject, $selectedEnvironment] = createV5ProjectWithEnvironment($team, 'zebra-project', 'staging');
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/selection', [
|
||||
'project_uuid' => $selectedProject->uuid,
|
||||
'environment_uuid' => $selectedEnvironment->uuid,
|
||||
])
|
||||
->assertNoContent()
|
||||
->assertSessionHas('v5.selectedProjectUuid', $selectedProject->uuid)
|
||||
->assertSessionHas('v5.selectedEnvironmentUuid', $selectedEnvironment->uuid);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('"selectedProjectUuid":"'.$selectedProject->uuid.'"', false)
|
||||
->assertSee('"selectedEnvironmentUuid":"'.$selectedEnvironment->uuid.'"', false);
|
||||
});
|
||||
|
||||
it('rejects persisted v5 selections outside the current team', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
$otherTeam = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'Other V5 Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
[$otherProject, $otherEnvironment] = createV5ProjectWithEnvironment($otherTeam, 'secret-saucer', 'private');
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->postJson('/v5/selection', [
|
||||
'project_uuid' => $otherProject->uuid,
|
||||
'environment_uuid' => $otherEnvironment->uuid,
|
||||
])
|
||||
->assertForbidden()
|
||||
->assertSessionMissing('v5.selectedProjectUuid')
|
||||
->assertSessionMissing('v5.selectedEnvironmentUuid');
|
||||
});
|
||||
|
||||
it('serves v5 dev assets from the current request host', function (string $url, string $viteHost) {
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
$hotFile = public_path('hot');
|
||||
$originalHotFile = file_exists($hotFile) ? file_get_contents($hotFile) : null;
|
||||
|
||||
file_put_contents($hotFile, 'http://configured-vite-host.test:5173');
|
||||
|
||||
try {
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get($url);
|
||||
|
||||
$response
|
||||
->assertSuccessful()
|
||||
->assertSee("import RefreshRuntime from 'http://{$viteHost}:5173/@react-refresh'", false)
|
||||
->assertSee("src=\"http://{$viteHost}:5173/@vite/client\"", false)
|
||||
->assertSee("src=\"http://{$viteHost}:5173/resources/js/v5/app.tsx\"", false)
|
||||
->assertDontSee('configured-vite-host.test', false);
|
||||
} finally {
|
||||
if ($originalHotFile === null) {
|
||||
@unlink($hotFile);
|
||||
} else {
|
||||
file_put_contents($hotFile, $originalHotFile);
|
||||
}
|
||||
}
|
||||
})->with([
|
||||
'localhost' => ['http://localhost:8000/v5', 'localhost'],
|
||||
'tailscale ip' => ['http://100.64.0.10:8000/v5', '100.64.0.10'],
|
||||
]);
|
||||
|
||||
it('configures the vite dev server for remote v5 access', function () {
|
||||
$viteConfig = file_get_contents(base_path('vite.config.js'));
|
||||
|
||||
expect($viteConfig)
|
||||
->toContain('host: "0.0.0.0"')
|
||||
->toContain('allowedHosts: true')
|
||||
->toContain('cors: true')
|
||||
->toContain('const viteHmrHost = env.VITE_HMR_HOST || null;')
|
||||
->toContain('hmr: viteHmrHost')
|
||||
->not->toContain('hmr: viteHost');
|
||||
});
|
||||
|
||||
it('shows when flux is unavailable', function () {
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth(false, 'Flux socket was not found.');
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
$user = User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Katherine Johnson',
|
||||
'email' => 'katherine@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
$team = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'Flux Test Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('Unavailable')
|
||||
->assertSee('Flux socket was not found.');
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\V5\EnsureCurrentTeam;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
beforeEach(function () {
|
||||
Config::set('cache.default', 'array');
|
||||
|
||||
Schema::dropIfExists('team_user');
|
||||
Schema::dropIfExists('teams');
|
||||
Schema::dropIfExists('users');
|
||||
|
||||
Schema::create('users', function ($table) {
|
||||
$table->id();
|
||||
$table->string('name')->default('Anonymous');
|
||||
$table->string('email');
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('teams', function ($table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('description')->nullable();
|
||||
$table->boolean('personal_team')->default(false);
|
||||
$table->boolean('show_boarding')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('team_user', function ($table) {
|
||||
$table->id();
|
||||
$table->foreignId('team_id');
|
||||
$table->foreignId('user_id');
|
||||
$table->string('role')->default('member');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['team_id', 'user_id']);
|
||||
});
|
||||
});
|
||||
|
||||
function createEnsureCurrentTeamUser(string $email = 'margaret@example.com'): User
|
||||
{
|
||||
return User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Margaret Hamilton',
|
||||
'email' => $email,
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
}
|
||||
|
||||
function createEnsureCurrentTeamTeam(string $name = 'V5 Tooling Team'): Team
|
||||
{
|
||||
return Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => $name,
|
||||
'description' => 'The team that ships v5.',
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the middleware against a request the way the v5.authenticated group
|
||||
* would: an authenticated user with an active session store.
|
||||
*
|
||||
* @return array{0: Response, 1: Request}
|
||||
*/
|
||||
function runEnsureCurrentTeam(?User $user): array
|
||||
{
|
||||
$request = Request::create('/v5', 'GET');
|
||||
$request->setLaravelSession(app('session.store'));
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$response = (new EnsureCurrentTeam)->handle($request, fn () => response()->noContent());
|
||||
|
||||
return [$response, $request];
|
||||
}
|
||||
|
||||
it('aborts 403 when the user belongs to no team', function () {
|
||||
$user = createEnsureCurrentTeamUser();
|
||||
|
||||
expect(fn () => runEnsureCurrentTeam($user))
|
||||
->toThrow(fn (HttpException $exception) => expect($exception->getStatusCode())->toBe(403));
|
||||
});
|
||||
|
||||
it('falls back to first team when the session team is no longer a member', function () {
|
||||
$user = createEnsureCurrentTeamUser();
|
||||
$memberTeam = createEnsureCurrentTeamTeam('Member Team');
|
||||
$foreignTeam = createEnsureCurrentTeamTeam('Foreign Team');
|
||||
$user->teams()->attach($memberTeam, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $foreignTeam]);
|
||||
|
||||
[, $request] = runEnsureCurrentTeam($user);
|
||||
|
||||
expect($request->attributes->get('v5.currentTeam')->id)->toBe($memberTeam->id)
|
||||
->and(session('currentTeam'))->toBeInstanceOf(Team::class)
|
||||
->and(session('currentTeam')->id)->toBe($memberTeam->id)
|
||||
->and(session('currentTeam')->name)->toBe('Member Team');
|
||||
});
|
||||
|
||||
it('stores the full team model so v4 session reads keep working', function () {
|
||||
$user = createEnsureCurrentTeamUser();
|
||||
$team = createEnsureCurrentTeamTeam();
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
|
||||
runEnsureCurrentTeam($user);
|
||||
|
||||
$sessionTeam = session('currentTeam');
|
||||
|
||||
// v4 reads columns beyond id/name/description/personal_team off the
|
||||
// session team (e.g. show_boarding, custom limits), so the stored model
|
||||
// must not be a partial select.
|
||||
expect($sessionTeam)->toBeInstanceOf(Team::class)
|
||||
->and($sessionTeam->getAttributes())->toHaveKeys([
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'personal_team',
|
||||
'show_boarding',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])
|
||||
->and($sessionTeam->created_at)->not->toBeNull()
|
||||
->and((bool) $sessionTeam->show_boarding)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not rewrite the session when the team is unchanged', function () {
|
||||
$user = createEnsureCurrentTeamUser();
|
||||
$team = createEnsureCurrentTeamTeam();
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
|
||||
// Prime the session with the same team but a sentinel attribute; if the
|
||||
// middleware skips the redundant write, the sentinel survives the request.
|
||||
$sessionTeam = Team::query()->findOrFail($team->id);
|
||||
$sessionTeam->name = 'Session Sentinel';
|
||||
session(['currentTeam' => $sessionTeam]);
|
||||
|
||||
[, $request] = runEnsureCurrentTeam($user);
|
||||
|
||||
expect($request->attributes->get('v5.currentTeam'))->toBeInstanceOf(Team::class)
|
||||
->and($request->attributes->get('v5.currentTeam')->id)->toBe($team->id)
|
||||
->and($request->attributes->get('v5.currentTeam')->name)->toBe('V5 Tooling Team')
|
||||
->and(session('currentTeam'))->toBe($sessionTeam)
|
||||
->and(session('currentTeam')->name)->toBe('Session Sentinel');
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->keyDir = storage_path('framework/testing/flux-keygen-'.bin2hex(random_bytes(4)));
|
||||
$this->privateKeyPath = $this->keyDir.'/jwt.priv';
|
||||
$this->publicKeyPath = $this->keyDir.'/jwt.pub';
|
||||
|
||||
Config::set('flux.jwt_private_key_path', $this->privateKeyPath);
|
||||
Config::set('flux.jwt_public_key_path', $this->publicKeyPath);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
File::deleteDirectory($this->keyDir);
|
||||
});
|
||||
|
||||
/**
|
||||
* @return string octal file/dir mode, e.g. "0600"
|
||||
*/
|
||||
function fluxKeyMode(string $path): string
|
||||
{
|
||||
clearstatcache();
|
||||
|
||||
return sprintf('%04o', fileperms($path) & 0777);
|
||||
}
|
||||
|
||||
it('generates a working ES256 keypair at the configured paths with strict perms', function () {
|
||||
$this->artisan('v5:flux-generate-keys')->assertSuccessful();
|
||||
|
||||
expect(File::exists($this->privateKeyPath))->toBeTrue()
|
||||
->and(File::exists($this->publicKeyPath))->toBeTrue()
|
||||
->and(fluxKeyMode($this->privateKeyPath))->toBe('0600')
|
||||
->and(fluxKeyMode($this->publicKeyPath))->toBe('0644')
|
||||
->and(fluxKeyMode($this->keyDir))->toBe('0700');
|
||||
|
||||
// The generated private key must be usable by the real issuer...
|
||||
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
|
||||
|
||||
expect(substr_count($token, '.'))->toBe(2);
|
||||
|
||||
// ...and verifiable with the generated public key (proves the pair matches).
|
||||
$claims = JWT::decode($token, new Key(File::get($this->publicKeyPath), 'ES256'));
|
||||
|
||||
expect($claims->sub)->toBe('100.64.0.10');
|
||||
});
|
||||
|
||||
it('refuses to overwrite an existing private key without --force', function () {
|
||||
File::ensureDirectoryExists($this->keyDir);
|
||||
File::put($this->privateKeyPath, 'SENTINEL-PRIVATE-KEY');
|
||||
|
||||
$this->artisan('v5:flux-generate-keys')
|
||||
->expectsOutputToContain('already exists')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(File::get($this->privateKeyPath))->toBe('SENTINEL-PRIVATE-KEY');
|
||||
});
|
||||
|
||||
it('overwrites an existing private key with --force and produces a valid keypair', function () {
|
||||
File::ensureDirectoryExists($this->keyDir);
|
||||
File::put($this->privateKeyPath, 'SENTINEL-PRIVATE-KEY');
|
||||
|
||||
$this->artisan('v5:flux-generate-keys --force')->assertSuccessful();
|
||||
|
||||
expect(File::get($this->privateKeyPath))->not->toBe('SENTINEL-PRIVATE-KEY')
|
||||
->and(fluxKeyMode($this->privateKeyPath))->toBe('0600');
|
||||
|
||||
$token = app(AgentTokenIssuer::class)->issue('100.64.0.10');
|
||||
|
||||
expect(substr_count($token, '.'))->toBe(2);
|
||||
});
|
||||
|
||||
it('prints the public key when --show-public is passed', function () {
|
||||
$this->artisan('v5:flux-generate-keys --show-public')
|
||||
->expectsOutputToContain('BEGIN PUBLIC KEY')
|
||||
->assertSuccessful();
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use Tests\Support\V5TestSchema;
|
||||
|
||||
beforeEach(function () {
|
||||
Config::set('broadcasting.default', 'log');
|
||||
Config::set('cache.default', 'array');
|
||||
|
||||
V5TestSchema::dropAllTables();
|
||||
V5TestSchema::createAllTables();
|
||||
|
||||
V5Server::query()->create([
|
||||
'team_id' => 1,
|
||||
'created_by_user_id' => 1,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
V5TestSchema::dropAllTables();
|
||||
});
|
||||
|
||||
function postFluxContainerStatus(string $bearer): TestResponse
|
||||
{
|
||||
return test()->postJson('/api/v1/internal/flux/resource-status', [
|
||||
'resource_type' => 'container',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_id' => 'external-container-id',
|
||||
'container_name' => 'external-container',
|
||||
'status' => 'running',
|
||||
], [
|
||||
'Authorization' => 'Bearer '.$bearer,
|
||||
]);
|
||||
}
|
||||
|
||||
it('accepts an inbound token listed in the rotatable token array', function () {
|
||||
Config::set('flux.laravel_api_token', null);
|
||||
Config::set('flux.laravel_api_tokens', ['old-token', 'new-token']);
|
||||
|
||||
postFluxContainerStatus('new-token')->assertSuccessful();
|
||||
postFluxContainerStatus('old-token')->assertSuccessful();
|
||||
});
|
||||
|
||||
it('still accepts the single fallback token alongside the array', function () {
|
||||
Config::set('flux.laravel_api_token', 'single-token');
|
||||
Config::set('flux.laravel_api_tokens', ['array-token']);
|
||||
|
||||
postFluxContainerStatus('single-token')->assertSuccessful();
|
||||
postFluxContainerStatus('array-token')->assertSuccessful();
|
||||
});
|
||||
|
||||
it('rejects a token that is in neither the array nor the fallback', function () {
|
||||
Config::set('flux.laravel_api_token', 'single-token');
|
||||
Config::set('flux.laravel_api_tokens', ['array-token']);
|
||||
|
||||
postFluxContainerStatus('unknown-token')->assertUnauthorized();
|
||||
});
|
||||
|
||||
it('rejects an empty bearer token even when no tokens are configured', function () {
|
||||
Config::set('flux.laravel_api_token', null);
|
||||
Config::set('flux.laravel_api_tokens', []);
|
||||
|
||||
postFluxContainerStatus('')->assertUnauthorized();
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\V5\Flux\ApplyFluxResourceStatusUpdate;
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Enums\V5\ContainerState;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Events\V5CanvasResourceUpdated;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ContainerStatus;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\Support\V5TestSchema;
|
||||
|
||||
beforeEach(function () {
|
||||
Config::set('broadcasting.default', 'log');
|
||||
Config::set('cache.default', 'array');
|
||||
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_container_statuses');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
|
||||
V5TestSchema::createServersTable();
|
||||
V5TestSchema::createApplicationsTable();
|
||||
V5TestSchema::createContainerStatusesTable();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_container_statuses');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function createFluxIngestionServer(array $overrides = []): V5Server
|
||||
{
|
||||
return V5Server::query()->create([
|
||||
'team_id' => 1,
|
||||
'created_by_user_id' => 1,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => ServerStatus::Installed->value,
|
||||
'capabilities' => [],
|
||||
'wireguard_management_ip' => '100.64.0.5',
|
||||
...$overrides,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function createFluxIngestionApplication(V5Server $server, array $overrides = []): V5Application
|
||||
{
|
||||
return V5Application::query()->create([
|
||||
'team_id' => $server->team_id,
|
||||
'project_id' => 1,
|
||||
'environment_id' => 1,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => 1,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'status' => ApplicationStatus::Running->value,
|
||||
'status_message' => 'Container started.',
|
||||
'runtime_container_id' => 'nginx-container-id',
|
||||
...$overrides,
|
||||
]);
|
||||
}
|
||||
|
||||
it('maps unknown flux application status values to unknown and logs a warning', function () {
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
Log::spy();
|
||||
|
||||
$server = createFluxIngestionServer();
|
||||
$application = createFluxIngestionApplication($server);
|
||||
|
||||
ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'container_id' => 'nginx-container-id',
|
||||
'status' => 'Exploded',
|
||||
]);
|
||||
|
||||
expect($application->refresh()->status)->toBe(ApplicationStatus::Unknown->value);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn (string $message, array $context = []): bool => str_contains($message, 'unknown flux resource status')
|
||||
&& ($context['raw_status'] ?? null) === 'Exploded'
|
||||
&& ($context['status_enum'] ?? null) === ApplicationStatus::class)
|
||||
->once();
|
||||
});
|
||||
|
||||
it('maps unknown flux container states to unknown when upserting container statuses', function () {
|
||||
Log::spy();
|
||||
|
||||
$server = createFluxIngestionServer();
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'container',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_id' => 'external-container-id',
|
||||
'container_name' => 'external-container',
|
||||
'status' => 'levitating',
|
||||
]);
|
||||
|
||||
expect($resource)->toBeInstanceOf(ContainerStatus::class)
|
||||
->and($resource->status)->toBe(ContainerState::Unknown->value);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn (string $message, array $context = []): bool => ($context['raw_status'] ?? null) === 'levitating'
|
||||
&& ($context['status_enum'] ?? null) === ContainerState::class)
|
||||
->once();
|
||||
});
|
||||
|
||||
it('still applies known statuses case insensitively without warnings', function () {
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
Log::spy();
|
||||
|
||||
$server = createFluxIngestionServer();
|
||||
$application = createFluxIngestionApplication($server);
|
||||
|
||||
ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'container_id' => 'nginx-container-id',
|
||||
'status' => 'EXITED',
|
||||
]);
|
||||
|
||||
expect($application->refresh()->status)->toBe(ApplicationStatus::Exited->value);
|
||||
|
||||
Log::shouldNotHaveReceived('warning');
|
||||
});
|
||||
|
||||
it('drops flux application status updates that are older than the persisted observation', function () {
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
Log::spy();
|
||||
|
||||
$server = createFluxIngestionServer();
|
||||
$application = createFluxIngestionApplication($server, [
|
||||
'status_observed_at' => '2026-07-05T12:00:00Z',
|
||||
]);
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'container_id' => 'nginx-container-id',
|
||||
'status' => 'exited',
|
||||
'observed_at' => '2026-07-05T11:00:00Z',
|
||||
]);
|
||||
|
||||
expect($resource)->toBeInstanceOf(V5Application::class)
|
||||
->and($application->refresh()->status)->toBe(ApplicationStatus::Running->value)
|
||||
->and($application->status_observed_at->toIso8601String())->toBe('2026-07-05T12:00:00+00:00');
|
||||
|
||||
Log::shouldHaveReceived('debug')
|
||||
->withArgs(fn (string $message): bool => str_contains($message, 'stale flux application status'))
|
||||
->once();
|
||||
|
||||
Event::assertNotDispatched(V5CanvasResourceUpdated::class);
|
||||
});
|
||||
|
||||
it('applies flux application status updates with a newer observation and persists the timestamp', function () {
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server = createFluxIngestionServer();
|
||||
$application = createFluxIngestionApplication($server, [
|
||||
'status_observed_at' => '2026-07-05T10:00:00Z',
|
||||
]);
|
||||
|
||||
ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'container_id' => 'nginx-container-id',
|
||||
'status' => 'exited',
|
||||
'status_message' => 'Container exited.',
|
||||
'observed_at' => '2026-07-05T12:00:00Z',
|
||||
]);
|
||||
|
||||
expect($application->refresh()->status)->toBe(ApplicationStatus::Exited->value)
|
||||
->and($application->status_message)->toBe('Container exited.')
|
||||
->and($application->status_observed_at->toIso8601String())->toBe('2026-07-05T12:00:00+00:00');
|
||||
|
||||
Event::assertDispatched(V5CanvasResourceUpdated::class);
|
||||
});
|
||||
|
||||
it('keeps applying flux application status updates that carry no observation timestamp', function () {
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server = createFluxIngestionServer();
|
||||
$application = createFluxIngestionApplication($server, [
|
||||
'status_observed_at' => '2026-07-05T12:00:00Z',
|
||||
]);
|
||||
|
||||
ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'container_id' => 'nginx-container-id',
|
||||
'status' => 'exited',
|
||||
]);
|
||||
|
||||
expect($application->refresh()->status)->toBe(ApplicationStatus::Exited->value)
|
||||
->and($application->status_observed_at->toIso8601String())->toBe('2026-07-05T12:00:00+00:00');
|
||||
});
|
||||
|
||||
it('drops stale flux server status updates but applies newer observations', function () {
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
Log::spy();
|
||||
|
||||
$server = createFluxIngestionServer([
|
||||
'status_observed_at' => '2026-07-05T12:00:00Z',
|
||||
]);
|
||||
|
||||
ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'server',
|
||||
'host_id' => '100.64.0.5',
|
||||
'status' => 'unreachable',
|
||||
'observed_at' => '2026-07-05T11:59:59Z',
|
||||
]);
|
||||
|
||||
expect($server->refresh()->status)->toBe(ServerStatus::Installed->value);
|
||||
|
||||
Log::shouldHaveReceived('debug')
|
||||
->withArgs(fn (string $message): bool => str_contains($message, 'stale flux server status'))
|
||||
->once();
|
||||
|
||||
ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'server',
|
||||
'host_id' => '100.64.0.5',
|
||||
'status' => 'unreachable',
|
||||
'observed_at' => '2026-07-05T12:00:01Z',
|
||||
]);
|
||||
|
||||
expect($server->refresh()->status)->toBe(ServerStatus::Unreachable->value)
|
||||
->and($server->status_observed_at->toIso8601String())->toBe('2026-07-05T12:00:01+00:00');
|
||||
});
|
||||
|
||||
it('drops stale flux container status updates that are older than the persisted observation', function () {
|
||||
Log::spy();
|
||||
|
||||
$server = createFluxIngestionServer();
|
||||
ContainerStatus::query()->create([
|
||||
'team_id' => $server->team_id,
|
||||
'server_id' => $server->id,
|
||||
'container_id' => 'external-container-id',
|
||||
'container_name' => 'external-container',
|
||||
'status' => ContainerState::Running->value,
|
||||
'status_observed_at' => '2026-07-05T12:00:00Z',
|
||||
]);
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'container',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_id' => 'external-container-id',
|
||||
'container_name' => 'external-container',
|
||||
'status' => 'exited',
|
||||
'observed_at' => '2026-07-05T11:00:00Z',
|
||||
]);
|
||||
|
||||
expect($resource)->toBeInstanceOf(ContainerStatus::class)
|
||||
->and($resource->status)->toBe(ContainerState::Running->value);
|
||||
|
||||
Log::shouldHaveReceived('debug')
|
||||
->withArgs(fn (string $message): bool => str_contains($message, 'stale flux container status'))
|
||||
->once();
|
||||
});
|
||||
|
||||
it('logs a warning when a flux host id matches multiple v5 servers', function () {
|
||||
Log::spy();
|
||||
|
||||
$serverOne = createFluxIngestionServer();
|
||||
$serverTwo = createFluxIngestionServer([
|
||||
'name' => 'edge-02',
|
||||
'host' => '203.0.113.11',
|
||||
]);
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'server',
|
||||
'host_id' => '100.64.0.5',
|
||||
'status' => 'unreachable',
|
||||
]);
|
||||
|
||||
expect($resource)->toBeNull()
|
||||
->and($serverOne->refresh()->status)->toBe(ServerStatus::Installed->value)
|
||||
->and($serverTwo->refresh()->status)->toBe(ServerStatus::Installed->value);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn (string $message, array $context = []): bool => str_contains($message, 'matches multiple v5 servers')
|
||||
&& ($context['host_id'] ?? null) === '100.64.0.5'
|
||||
&& ($context['server_ids'] ?? null) === [$serverOne->id, $serverTwo->id])
|
||||
->once();
|
||||
});
|
||||
|
||||
it('accepts flux resource status http payloads with a valid observed_at timestamp', function () {
|
||||
Config::set('flux.laravel_api_token', 'test-flux-token');
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server = createFluxIngestionServer();
|
||||
$application = createFluxIngestionApplication($server);
|
||||
|
||||
$this
|
||||
->withToken('test-flux-token')
|
||||
->postJson('/api/v1/internal/flux/resource-status', [
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'container_name' => 'coolify-v5-nginx-1',
|
||||
'container_id' => 'nginx-container-id',
|
||||
'status' => 'exited',
|
||||
'observed_at' => '2026-07-05T12:00:00Z',
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('message', 'Resource status updated.');
|
||||
|
||||
expect($application->refresh()->status)->toBe(ApplicationStatus::Exited->value)
|
||||
->and($application->status_observed_at->toIso8601String())->toBe('2026-07-05T12:00:00+00:00');
|
||||
});
|
||||
|
||||
it('rejects flux resource status http payloads with an invalid observed_at value', function () {
|
||||
Config::set('flux.laravel_api_token', 'test-flux-token');
|
||||
|
||||
$this
|
||||
->withToken('test-flux-token')
|
||||
->postJson('/api/v1/internal/flux/resource-status', [
|
||||
'resource_type' => 'application',
|
||||
'host_id' => '100.64.0.5',
|
||||
'status' => 'exited',
|
||||
'observed_at' => 'not-a-timestamp',
|
||||
])
|
||||
->assertInvalid(['observed_at']);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @param string|array<int, string> $queue
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function horizonSupervisorQueues(string|array $queue): array
|
||||
{
|
||||
$queues = is_array($queue) ? $queue : explode(',', $queue);
|
||||
|
||||
return array_map('trim', $queues);
|
||||
}
|
||||
|
||||
it('defines a horizon supervisor that consumes the v5-reconcile queue', function () {
|
||||
$supervisors = config('horizon.defaults');
|
||||
|
||||
$handlesReconcile = collect($supervisors)->contains(
|
||||
fn (array $config): bool => in_array('v5-reconcile', horizonSupervisorQueues($config['queue'] ?? ''), true)
|
||||
);
|
||||
|
||||
expect($handlesReconcile)->toBeTrue();
|
||||
});
|
||||
|
||||
it('provisions the v5-reconcile supervisor in both production and local environments', function () {
|
||||
expect(config('horizon.environments.production.v5reconcile'))->toBeArray()
|
||||
->and(config('horizon.environments.local.v5reconcile'))->toBeArray()
|
||||
->and(config('horizon.defaults.v5reconcile.queue'))->toBe('v5-reconcile');
|
||||
});
|
||||
|
||||
it('keeps the v5-reconcile supervisor isolated from the user-facing deploy queues', function () {
|
||||
$reconcileQueues = horizonSupervisorQueues(config('horizon.defaults.v5reconcile.queue'));
|
||||
|
||||
expect($reconcileQueues)->not->toContain('high')
|
||||
->and($reconcileQueues)->not->toContain('default')
|
||||
->and(config('horizon.defaults.v5reconcile.nice'))->toBeGreaterThan(0);
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\V5ReconcileServersJob;
|
||||
use App\Jobs\V5ReconcileServerStateJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ApplicationDomain;
|
||||
use App\Models\V5\ContainerStatus;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Models\V5\ResourceConnectionRule;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\Support\V5TestSchema;
|
||||
|
||||
beforeEach(function () {
|
||||
Config::set('broadcasting.default', 'log');
|
||||
Config::set('cache.default', 'array');
|
||||
|
||||
dropModelHygieneTables();
|
||||
createModelHygieneTables();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
dropModelHygieneTables();
|
||||
});
|
||||
|
||||
it('registers the v5 application morph alias without enforcing a global morph map', function () {
|
||||
expect((new V5Application)->getMorphClass())->toBe('v5.application')
|
||||
->and(Relation::getMorphedModel('v5.application'))->toBe(V5Application::class)
|
||||
// v4 models keep storing FQCNs — the map must not be enforced.
|
||||
->and((new Application)->getMorphClass())->toBe(Application::class);
|
||||
});
|
||||
|
||||
it('stores v5 resource connections with the morph alias and resolves it back', function () {
|
||||
$source = createModelHygieneApplication('api');
|
||||
$target = createModelHygieneApplication('postgres');
|
||||
|
||||
$connection = ResourceConnection::query()->create([
|
||||
'team_id' => 1,
|
||||
'project_id' => 1,
|
||||
'environment_id' => 1,
|
||||
'resource_one_type' => $source->getMorphClass(),
|
||||
'resource_one_id' => $source->id,
|
||||
'resource_two_type' => $target->getMorphClass(),
|
||||
'resource_two_id' => $target->id,
|
||||
'resource_pair_key' => collect([
|
||||
$source->getMorphClass().':'.$source->getKey(),
|
||||
$target->getMorphClass().':'.$target->getKey(),
|
||||
])->sort()->implode('|'),
|
||||
'created_by_user_id' => 1,
|
||||
]);
|
||||
|
||||
$storedRow = DB::table('v5_resource_connections')->where('id', $connection->id)->first();
|
||||
|
||||
expect($storedRow->resource_one_type)->toBe('v5.application')
|
||||
->and($storedRow->resource_two_type)->toBe('v5.application')
|
||||
->and($storedRow->resource_pair_key)->toBe("v5.application:{$source->id}|v5.application:{$target->id}")
|
||||
->and($connection->refresh()->resourceOne)->toBeInstanceOf(V5Application::class)
|
||||
->and($connection->resourceOne->id)->toBe($source->id)
|
||||
->and($connection->resourceTwo->id)->toBe($target->id);
|
||||
});
|
||||
|
||||
it('rewrites legacy fqcn morph rows to the v5 application alias', function () {
|
||||
$fqcn = 'App\Models\V5\Application';
|
||||
|
||||
DB::table('v5_resource_connections')->insert([
|
||||
'uuid' => 'legacy-connection',
|
||||
'team_id' => 1,
|
||||
'project_id' => 1,
|
||||
'environment_id' => 1,
|
||||
'resource_one_type' => $fqcn,
|
||||
'resource_one_id' => 1,
|
||||
'resource_two_type' => $fqcn,
|
||||
'resource_two_id' => 2,
|
||||
'resource_pair_key' => "{$fqcn}:1|{$fqcn}:2",
|
||||
'created_by_user_id' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('v5_resource_connection_rules')->insert([
|
||||
'connection_id' => 1,
|
||||
'source_resource_type' => $fqcn,
|
||||
'source_resource_id' => 1,
|
||||
'target_resource_type' => $fqcn,
|
||||
'target_resource_id' => 2,
|
||||
'protocol' => 'tcp',
|
||||
'port' => 5432,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$migration = include database_path('migrations-v5/2026_07_06_090000_v5_convert_resource_connection_morphs_to_aliases.php');
|
||||
$migration->up();
|
||||
|
||||
$connection = DB::table('v5_resource_connections')->where('uuid', 'legacy-connection')->first();
|
||||
$rule = DB::table('v5_resource_connection_rules')->first();
|
||||
|
||||
expect($connection->resource_one_type)->toBe('v5.application')
|
||||
->and($connection->resource_two_type)->toBe('v5.application')
|
||||
->and($connection->resource_pair_key)->toBe('v5.application:1|v5.application:2')
|
||||
->and($rule->source_resource_type)->toBe('v5.application')
|
||||
->and($rule->target_resource_type)->toBe('v5.application');
|
||||
});
|
||||
|
||||
it('maps the capabilities array onto the indexed booleans', function () {
|
||||
$server = new V5Server(['capabilities' => ['ingress']]);
|
||||
|
||||
expect($server->is_ingress)->toBeTrue()
|
||||
->and($server->has_coold)->toBeFalse()
|
||||
->and($server->isIngress())->toBeTrue()
|
||||
->and($server->hasCapability('ingress'))->toBeTrue()
|
||||
->and($server->hasCapability('coold'))->toBeFalse()
|
||||
->and($server->hasCapability('builder'))->toBeFalse()
|
||||
->and($server->capabilities)->toBe(['ingress']);
|
||||
|
||||
$server->has_coold = true;
|
||||
|
||||
expect($server->capabilities)->toBe(['coold', 'ingress']);
|
||||
|
||||
$server->capabilities = [];
|
||||
|
||||
expect($server->has_coold)->toBeFalse()
|
||||
->and($server->is_ingress)->toBeFalse()
|
||||
->and($server->capabilities)->toBe([]);
|
||||
});
|
||||
|
||||
it('persists capabilities writes into the boolean columns', function () {
|
||||
$server = createModelHygieneServer(['capabilities' => ['coold']]);
|
||||
|
||||
$storedRow = DB::table('v5_servers')->where('id', $server->id)->first();
|
||||
|
||||
expect((bool) $storedRow->has_coold)->toBeTrue()
|
||||
->and((bool) $storedRow->is_ingress)->toBeFalse()
|
||||
->and($server->fresh()->capabilities)->toBe(['coold']);
|
||||
});
|
||||
|
||||
it('backfills the capability booleans from the legacy json column', function () {
|
||||
Schema::dropIfExists('v5_servers');
|
||||
Schema::create('v5_servers', function ($table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->nullable()->unique();
|
||||
$table->string('name');
|
||||
$table->json('capabilities')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
DB::table('v5_servers')->insert([
|
||||
['name' => 'full', 'capabilities' => json_encode(['coold', 'ingress'])],
|
||||
['name' => 'ingress-only', 'capabilities' => json_encode(['ingress'])],
|
||||
['name' => 'unknown-only', 'capabilities' => json_encode(['builder'])],
|
||||
['name' => 'empty', 'capabilities' => null],
|
||||
]);
|
||||
|
||||
$migration = include database_path('migrations-v5/2026_07_06_090100_v5_convert_server_capabilities_to_booleans.php');
|
||||
$migration->up();
|
||||
|
||||
$serversByName = DB::table('v5_servers')->get()->keyBy('name');
|
||||
|
||||
expect(Schema::hasColumn('v5_servers', 'capabilities'))->toBeFalse()
|
||||
->and((bool) $serversByName['full']->has_coold)->toBeTrue()
|
||||
->and((bool) $serversByName['full']->is_ingress)->toBeTrue()
|
||||
->and((bool) $serversByName['ingress-only']->has_coold)->toBeFalse()
|
||||
->and((bool) $serversByName['ingress-only']->is_ingress)->toBeTrue()
|
||||
->and((bool) $serversByName['unknown-only']->has_coold)->toBeFalse()
|
||||
->and((bool) $serversByName['unknown-only']->is_ingress)->toBeFalse()
|
||||
->and((bool) $serversByName['empty']->has_coold)->toBeFalse()
|
||||
->and((bool) $serversByName['empty']->is_ingress)->toBeFalse();
|
||||
});
|
||||
|
||||
it('selects reconcile-eligible servers through the indexed has_coold column', function () {
|
||||
Queue::fake();
|
||||
|
||||
$eligible = createModelHygieneServer(['name' => 'coold-node', 'host' => '203.0.113.10', 'has_coold' => true]);
|
||||
createModelHygieneServer(['name' => 'plain-node', 'host' => '203.0.113.11', 'has_coold' => false]);
|
||||
|
||||
DB::enableQueryLog();
|
||||
(new V5ReconcileServersJob)->handle();
|
||||
$queries = collect(DB::getQueryLog())->pluck('query')->implode(' ');
|
||||
DB::disableQueryLog();
|
||||
DB::flushQueryLog();
|
||||
|
||||
expect($queries)->toContain('has_coold');
|
||||
Queue::assertPushed(V5ReconcileServerStateJob::class, 1);
|
||||
Queue::assertPushed(
|
||||
V5ReconcileServerStateJob::class,
|
||||
fn (V5ReconcileServerStateJob $job) => $job->serverId === $eligible->id,
|
||||
);
|
||||
});
|
||||
|
||||
it('generates uuids only for models whose tables have a uuid column', function () {
|
||||
$server = createModelHygieneServer();
|
||||
|
||||
expect($server->uuid)->not->toBeNull()
|
||||
->and($server->getRouteKeyName())->toBe('uuid');
|
||||
|
||||
// v5_container_statuses has no uuid column: the insert would fail if the
|
||||
// base model still tried to assign one.
|
||||
$containerStatus = ContainerStatus::query()->create([
|
||||
'team_id' => 1,
|
||||
'server_id' => $server->id,
|
||||
'container_id' => 'abc123',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
expect($containerStatus->getAttribute('uuid'))->toBeNull()
|
||||
->and($containerStatus->getRouteKeyName())->toBe('id')
|
||||
->and((new ApplicationDomain)->getRouteKeyName())->toBe('id')
|
||||
->and((new ResourceConnectionRule)->getRouteKeyName())->toBe('id');
|
||||
});
|
||||
|
||||
it('keeps explicitly provided uuids', function () {
|
||||
$server = createModelHygieneServer(['uuid' => 'chosen-uuid']);
|
||||
|
||||
expect($server->uuid)->toBe('chosen-uuid');
|
||||
});
|
||||
|
||||
it('regenerates a colliding public id before inserting', function () {
|
||||
createModelHygieneServer(['uuid' => 'taken-uuid']);
|
||||
|
||||
$colliding = new class extends V5Server
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
public array $candidates = ['taken-uuid', 'fresh-uuid'];
|
||||
|
||||
protected function newPublicIdCandidate(): string
|
||||
{
|
||||
return array_shift($this->candidates);
|
||||
}
|
||||
};
|
||||
|
||||
$colliding->forceFill([
|
||||
'team_id' => 1,
|
||||
'created_by_user_id' => 1,
|
||||
'name' => 'collision-node',
|
||||
'host' => '203.0.113.99',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
])->save();
|
||||
|
||||
expect($colliding->uuid)->toBe('fresh-uuid');
|
||||
});
|
||||
|
||||
function dropModelHygieneTables(): void
|
||||
{
|
||||
Schema::dropIfExists('v5_resource_connection_rules');
|
||||
Schema::dropIfExists('v5_resource_connections');
|
||||
Schema::dropIfExists('v5_application_domains');
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_container_statuses');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
}
|
||||
|
||||
function createModelHygieneTables(): void
|
||||
{
|
||||
V5TestSchema::createServersTable();
|
||||
V5TestSchema::createContainerStatusesTable();
|
||||
V5TestSchema::createApplicationsTable();
|
||||
V5TestSchema::createApplicationDomainsTable();
|
||||
V5TestSchema::createResourceConnectionsTable();
|
||||
V5TestSchema::createResourceConnectionRulesTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function createModelHygieneServer(array $overrides = []): V5Server
|
||||
{
|
||||
return V5Server::query()->create([
|
||||
'team_id' => 1,
|
||||
'created_by_user_id' => 1,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.'.random_int(2, 250),
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
...$overrides,
|
||||
]);
|
||||
}
|
||||
|
||||
function createModelHygieneApplication(string $name): V5Application
|
||||
{
|
||||
return V5Application::query()->create([
|
||||
'team_id' => 1,
|
||||
'project_id' => 1,
|
||||
'environment_id' => 1,
|
||||
'created_by_user_id' => 1,
|
||||
'name' => $name,
|
||||
'image' => "docker.io/library/{$name}:latest",
|
||||
'container_name' => "coolify-v5-{$name}",
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\V5\Flux\ApplyFluxResourceStatusUpdate;
|
||||
use App\Events\V5CanvasResourceUpdated;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Jobs\V5ReconcileServersJob;
|
||||
use App\Jobs\V5ReconcileServerStateJob;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ContainerStatus;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\Support\V5TestSchema;
|
||||
|
||||
beforeEach(function () {
|
||||
Config::set('broadcasting.default', 'log');
|
||||
Config::set('cache.default', 'array');
|
||||
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_container_statuses');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
});
|
||||
|
||||
function createReconcileV5Tables(): void
|
||||
{
|
||||
V5TestSchema::createServersTable();
|
||||
V5TestSchema::createContainerStatusesTable();
|
||||
V5TestSchema::createApplicationsTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
function createReconcileServer(array $attributes = []): V5Server
|
||||
{
|
||||
return V5Server::query()->create([
|
||||
'team_id' => 1,
|
||||
'created_by_user_id' => 1,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => ['coold'],
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
function createReconcileApplication(V5Server $server, array $attributes = []): V5Application
|
||||
{
|
||||
return V5Application::query()->create([
|
||||
'team_id' => $server->team_id,
|
||||
'project_id' => 1,
|
||||
'environment_id' => 1,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => 1,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-test',
|
||||
'status' => 'running',
|
||||
'runtime_container_id' => 'container-live',
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
it('marks a stale running application exited when its container is gone', function () {
|
||||
createReconcileV5Tables();
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server = createReconcileServer();
|
||||
$application = createReconcileApplication($server);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('listContainers')->once()->with($server->fluxHostId())->andReturn([]);
|
||||
|
||||
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
||||
|
||||
expect($application->refresh())
|
||||
->status->toBe('exited')
|
||||
->status_message->toBe('Container not found on server during reconcile.');
|
||||
expect($server->refresh())
|
||||
->status->toBe('installed')
|
||||
->last_status_check->toBe('reconcile')
|
||||
->last_status_checked_at->not->toBeNull();
|
||||
|
||||
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->applicationId === $application->id);
|
||||
});
|
||||
|
||||
it('updates application status from the observed container state and refreshes container statuses', function () {
|
||||
createReconcileV5Tables();
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server = createReconcileServer();
|
||||
$application = createReconcileApplication($server, ['runtime_container_id' => null]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('listContainers')->once()->andReturn([
|
||||
[
|
||||
'id' => 'container-live',
|
||||
'name' => 'coolify-v5-nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'state' => 'Restarting',
|
||||
],
|
||||
]);
|
||||
|
||||
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
||||
|
||||
expect($application->refresh())
|
||||
->status->toBe('restarting')
|
||||
->runtime_container_id->toBe('container-live');
|
||||
|
||||
$containerStatus = ContainerStatus::query()
|
||||
->where('server_id', $server->id)
|
||||
->where('container_id', 'container-live')
|
||||
->first();
|
||||
|
||||
expect($containerStatus)->not->toBeNull()
|
||||
->and($containerStatus->status)->toBe('restarting')
|
||||
->and($containerStatus->last_seen_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a creating application without a container id alone during reconcile', function () {
|
||||
createReconcileV5Tables();
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server = createReconcileServer();
|
||||
$application = createReconcileApplication($server, [
|
||||
'status' => 'creating',
|
||||
'runtime_container_id' => null,
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('listContainers')->once()->andReturn([]);
|
||||
|
||||
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
||||
|
||||
expect($application->refresh())->status->toBe('creating');
|
||||
});
|
||||
|
||||
it('marks an installed server unreachable when the node cannot be reached', function () {
|
||||
createReconcileV5Tables();
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server = createReconcileServer();
|
||||
$application = createReconcileApplication($server);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('listContainers')->once()->andThrow(new RuntimeException('Flux did not return a response before the timeout.'));
|
||||
|
||||
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
||||
|
||||
expect($server->refresh())
|
||||
->status->toBe('unreachable')
|
||||
->last_status_check->toBe('reconcile')
|
||||
->last_status_output->toContain('Flux did not return a response');
|
||||
expect($application->refresh())->status->toBe('running');
|
||||
|
||||
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->serverId === $server->id);
|
||||
});
|
||||
|
||||
it('does not mark a failed server unreachable when the node cannot be reached', function () {
|
||||
createReconcileV5Tables();
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server = createReconcileServer(['status' => 'failed']);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('listContainers')->once()->andThrow(new RuntimeException('boom'));
|
||||
|
||||
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
||||
|
||||
expect($server->refresh())
|
||||
->status->toBe('failed')
|
||||
->last_status_check->toBe('reconcile');
|
||||
});
|
||||
|
||||
it('restores an unreachable server to installed on a successful reconcile', function () {
|
||||
createReconcileV5Tables();
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server = createReconcileServer(['status' => 'unreachable']);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('listContainers')->once()->andReturn([]);
|
||||
|
||||
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
||||
|
||||
expect($server->refresh())
|
||||
->status->toBe('installed')
|
||||
->last_status_check->toBe('reconcile');
|
||||
|
||||
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->serverId === $server->id);
|
||||
});
|
||||
|
||||
it('dispatches per-server reconcile jobs only for managed servers', function () {
|
||||
createReconcileV5Tables();
|
||||
Queue::fake();
|
||||
|
||||
$managed = createReconcileServer(['host' => '203.0.113.10', 'wireguard_management_ip' => '100.64.0.10']);
|
||||
$unreachable = createReconcileServer([
|
||||
'name' => 'edge-02',
|
||||
'host' => '203.0.113.11',
|
||||
'wireguard_management_ip' => '100.64.0.11',
|
||||
'status' => 'unreachable',
|
||||
]);
|
||||
$withoutCoold = createReconcileServer([
|
||||
'name' => 'edge-03',
|
||||
'host' => '203.0.113.12',
|
||||
'wireguard_management_ip' => '100.64.0.12',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
$notInstalled = createReconcileServer([
|
||||
'name' => 'edge-04',
|
||||
'host' => '203.0.113.13',
|
||||
'wireguard_management_ip' => '100.64.0.13',
|
||||
'status' => 'added',
|
||||
]);
|
||||
|
||||
(new V5ReconcileServersJob)->handle();
|
||||
|
||||
Queue::assertPushed(V5ReconcileServerStateJob::class, 2);
|
||||
Queue::assertPushed(V5ReconcileServerStateJob::class, fn (V5ReconcileServerStateJob $job) => $job->serverId === $managed->id);
|
||||
Queue::assertPushed(V5ReconcileServerStateJob::class, fn (V5ReconcileServerStateJob $job) => $job->serverId === $unreachable->id);
|
||||
Queue::assertNotPushed(V5ReconcileServerStateJob::class, fn (V5ReconcileServerStateJob $job) => $job->serverId === $withoutCoold->id);
|
||||
Queue::assertNotPushed(V5ReconcileServerStateJob::class, fn (V5ReconcileServerStateJob $job) => $job->serverId === $notInstalled->id);
|
||||
});
|
||||
|
||||
it('drops a reconcile write when the stored observation is newer than the snapshot', function () {
|
||||
createReconcileV5Tables();
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server = createReconcileServer();
|
||||
$application = createReconcileApplication($server, [
|
||||
'status' => 'running',
|
||||
'runtime_container_id' => 'container-live',
|
||||
// A fresher webhook already advanced the watermark past this pull.
|
||||
'status_observed_at' => now()->addMinutes(5),
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('listContainers')->once()->andReturn([
|
||||
['id' => 'container-live', 'name' => 'coolify-v5-nginx-test', 'state' => 'exited'],
|
||||
]);
|
||||
|
||||
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
||||
|
||||
expect($application->refresh())
|
||||
->status->toBe('running')
|
||||
->status_observed_at->not->toBeNull();
|
||||
|
||||
Event::assertNotDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->applicationId === $application->id);
|
||||
});
|
||||
|
||||
it('advances status_observed_at when reconcile writes an application status', function () {
|
||||
createReconcileV5Tables();
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server = createReconcileServer();
|
||||
$application = createReconcileApplication($server, [
|
||||
'status' => 'running',
|
||||
'runtime_container_id' => 'container-live',
|
||||
'status_observed_at' => null,
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('listContainers')->once()->andReturn([
|
||||
['id' => 'container-live', 'name' => 'coolify-v5-nginx-test', 'state' => 'restarting'],
|
||||
]);
|
||||
|
||||
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
||||
|
||||
expect($application->refresh())
|
||||
->status->toBe('restarting')
|
||||
->status_observed_at->not->toBeNull();
|
||||
});
|
||||
|
||||
it('normalizes the coold configured state to a valid status on reconcile', function () {
|
||||
createReconcileV5Tables();
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server = createReconcileServer();
|
||||
$application = createReconcileApplication($server);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('listContainers')->once()->andReturn([
|
||||
['id' => 'container-live', 'name' => 'coolify-v5-nginx-test', 'state' => 'configured'],
|
||||
]);
|
||||
|
||||
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
||||
|
||||
expect($application->refresh()->status)->toBe('configured');
|
||||
|
||||
$containerStatus = ContainerStatus::query()
|
||||
->where('server_id', $server->id)
|
||||
->where('container_id', 'container-live')
|
||||
->first();
|
||||
|
||||
expect($containerStatus)->not->toBeNull()
|
||||
->and($containerStatus->status)->toBe('configured');
|
||||
});
|
||||
|
||||
it('normalizes the coold configured state on the flux webhook path', function () {
|
||||
createReconcileV5Tables();
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server = createReconcileServer();
|
||||
$application = createReconcileApplication($server, ['runtime_container_id' => 'container-live']);
|
||||
|
||||
ApplyFluxResourceStatusUpdate::run([
|
||||
'resource_type' => 'application',
|
||||
'server_uuid' => $server->uuid,
|
||||
'application_uuid' => $application->uuid,
|
||||
'status' => 'configured',
|
||||
'runtime_container_id' => 'container-live',
|
||||
]);
|
||||
|
||||
expect($application->refresh()->status)->toBe('configured');
|
||||
});
|
||||
|
||||
it('does not broadcast when reconcile finds the application status unchanged', function () {
|
||||
createReconcileV5Tables();
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
|
||||
$server = createReconcileServer();
|
||||
$application = createReconcileApplication($server, [
|
||||
'status' => 'running',
|
||||
'runtime_container_id' => 'container-live',
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('listContainers')->once()->andReturn([
|
||||
['id' => 'container-live', 'name' => 'coolify-v5-nginx-test', 'state' => 'running'],
|
||||
]);
|
||||
|
||||
(new V5ReconcileServerStateJob($server->id))->handle($fluxClient);
|
||||
|
||||
expect($application->refresh()->status)->toBe('running');
|
||||
|
||||
Event::assertNotDispatched(V5CanvasResourceUpdated::class);
|
||||
});
|
||||
|
||||
it('runs the reconcile jobs on the dedicated v5-reconcile queue', function () {
|
||||
expect((new V5ReconcileServersJob)->queue)->toBe('v5-reconcile')
|
||||
->and((new V5ReconcileServerStateJob(1))->queue)->toBe('v5-reconcile');
|
||||
});
|
||||
|
||||
it('prunes stale and orphaned container statuses but keeps fresh and live rows', function () {
|
||||
createReconcileV5Tables();
|
||||
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||
Queue::fake();
|
||||
|
||||
$server = createReconcileServer();
|
||||
createReconcileApplication($server, ['runtime_container_id' => 'container-live']);
|
||||
|
||||
$staleRow = ContainerStatus::query()->create([
|
||||
'team_id' => 1,
|
||||
'server_id' => $server->id,
|
||||
'container_id' => 'container-stale',
|
||||
'status' => 'running',
|
||||
'last_seen_at' => now()->subHours(V5ReconcileServersJob::CONTAINER_STATUS_TTL_HOURS + 1),
|
||||
]);
|
||||
$staleButLiveRow = ContainerStatus::query()->create([
|
||||
'team_id' => 1,
|
||||
'server_id' => $server->id,
|
||||
'container_id' => 'container-live',
|
||||
'status' => 'running',
|
||||
'last_seen_at' => now()->subHours(V5ReconcileServersJob::CONTAINER_STATUS_TTL_HOURS + 1),
|
||||
]);
|
||||
$freshRow = ContainerStatus::query()->create([
|
||||
'team_id' => 1,
|
||||
'server_id' => $server->id,
|
||||
'container_id' => 'container-fresh',
|
||||
'status' => 'running',
|
||||
'last_seen_at' => now()->subHour(),
|
||||
]);
|
||||
$orphanedServerRow = ContainerStatus::query()->create([
|
||||
'team_id' => 1,
|
||||
'server_id' => $server->id + 999,
|
||||
'container_id' => 'container-orphaned',
|
||||
'status' => 'running',
|
||||
'last_seen_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
(new V5ReconcileServersJob)->handle();
|
||||
|
||||
expect(ContainerStatus::query()->find($staleRow->id))->toBeNull()
|
||||
->and(ContainerStatus::query()->find($orphanedServerRow->id))->toBeNull()
|
||||
->and(ContainerStatus::query()->find($staleButLiveRow->id))->not->toBeNull()
|
||||
->and(ContainerStatus::query()->find($freshRow->id))->not->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\V5\Server\RemoveBootstrapMarker;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
createSharedUserAndTeamTables();
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
function createRemoveMarkerServer(array $attributes = []): V5Server
|
||||
{
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
|
||||
return V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'prod-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 2222,
|
||||
'status' => 'installed',
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $command
|
||||
*/
|
||||
function removeMarkerSshKeyPath(array $command): ?string
|
||||
{
|
||||
$index = array_search('-i', $command, true);
|
||||
|
||||
return $index === false ? null : ($command[$index + 1] ?? null);
|
||||
}
|
||||
|
||||
it('returns false and runs no ssh command when the server has no private key', function () {
|
||||
Process::fake();
|
||||
|
||||
$server = createRemoveMarkerServer(['private_key_id' => null]);
|
||||
|
||||
expect(RemoveBootstrapMarker::run($server))->toBeFalse();
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('removes the on-host bootstrap identity over ssh and deletes the temporary key file', function () {
|
||||
Process::fake(['*' => Process::result(output: '')]);
|
||||
|
||||
$server = createRemoveMarkerServer();
|
||||
$privateKey = createV5PrivateKey($server->team, 'Marker Removal Key');
|
||||
$server->update(['private_key_id' => $privateKey->id]);
|
||||
|
||||
expect(RemoveBootstrapMarker::run($server->fresh()))->toBeTrue();
|
||||
|
||||
$keyPath = null;
|
||||
|
||||
Process::assertRan(function ($process) use (&$keyPath): bool {
|
||||
$command = $process->command;
|
||||
|
||||
if (! is_array($command) || ($command[0] ?? null) !== 'ssh') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$script = end($command);
|
||||
|
||||
if (! str_contains($script, '$SUDO rm -f /etc/coolify/v5-node.json /etc/coolify/host-jwt /etc/systemd/system/coold.service.d/10-flux.conf')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! in_array('root@203.0.113.10', $command, true) || ! in_array('2222', $command, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$keyPath = removeMarkerSshKeyPath($command);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
expect($keyPath)->not->toBeNull()
|
||||
->and(file_exists($keyPath))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false but still deletes the temporary key file when the ssh command fails', function () {
|
||||
Process::fake(['*' => Process::result(errorOutput: 'connection refused', exitCode: 255)]);
|
||||
|
||||
$server = createRemoveMarkerServer();
|
||||
$privateKey = createV5PrivateKey($server->team, 'Marker Removal Key');
|
||||
$server->update(['private_key_id' => $privateKey->id]);
|
||||
|
||||
expect(RemoveBootstrapMarker::run($server->fresh()))->toBeFalse();
|
||||
|
||||
$keyPath = null;
|
||||
|
||||
Process::assertRan(function ($process) use (&$keyPath): bool {
|
||||
if (! is_array($process->command) || ($process->command[0] ?? null) !== 'ssh') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$keyPath = removeMarkerSshKeyPath($process->command);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
expect($keyPath)->not->toBeNull()
|
||||
->and(file_exists($keyPath))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,626 @@
|
||||
<?php
|
||||
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Cluster;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Facades\Exceptions;
|
||||
|
||||
beforeEach(function () {
|
||||
resetV5DashboardTestState();
|
||||
});
|
||||
|
||||
it('uses v5 resource uuids for resource connection requests and responses', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.20',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
]);
|
||||
$source = V5Application::query()->create([
|
||||
'uuid' => 'source-application-uuid',
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'source',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'source-container',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$target = V5Application::query()->create([
|
||||
'uuid' => 'target-application-uuid',
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'target',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'target-container',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->withSession([
|
||||
'currentTeam' => $team,
|
||||
'v5.selectedProjectUuid' => $project->uuid,
|
||||
'v5.selectedEnvironmentUuid' => $environment->uuid,
|
||||
])
|
||||
->postJson('/v5/resource-connections', [
|
||||
'resource_one' => ['type' => 'application', 'uuid' => $source->uuid],
|
||||
'resource_two' => ['type' => 'application', 'uuid' => $target->uuid],
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('connection.fromApplicationId', $source->uuid)
|
||||
->assertJsonPath('connection.toApplicationId', $target->uuid);
|
||||
|
||||
$connectionUuid = $response->json('connection.id');
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('applyFirewallRule')->once()->andReturn('Firewall rule applied.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team])
|
||||
->patchJson("/v5/resource-connections/{$connectionUuid}", [
|
||||
'ports_by_direction' => [
|
||||
"{$source->uuid}->{$target->uuid}" => [8080],
|
||||
],
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonPath("connection.portsByDirection.{$source->uuid}->{$target->uuid}.0", '8080');
|
||||
|
||||
expect(ResourceConnection::query()->first())
|
||||
->resource_one_id->toBe($source->id)
|
||||
->resource_two_id->toBe($target->id);
|
||||
});
|
||||
|
||||
it('persists generic v5 resource connections and direction-specific ports', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
fakeFluxHealth();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
]);
|
||||
$source = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-source',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$target = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-nginx-target',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->withSession([
|
||||
'currentTeam' => $team,
|
||||
'v5.selectedProjectUuid' => $project->uuid,
|
||||
'v5.selectedEnvironmentUuid' => $environment->uuid,
|
||||
'_token' => 'test-csrf-token',
|
||||
])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->postJson('/v5/resource-connections', [
|
||||
'resource_one' => ['type' => 'application', 'uuid' => $source->uuid],
|
||||
'resource_two' => ['type' => 'application', 'uuid' => $target->uuid],
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('connection.applicationIds.0', $source->uuid)
|
||||
->assertJsonPath('connection.applicationIds.1', $target->uuid);
|
||||
|
||||
$connectionId = $response->json('connection.id');
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('applyFirewallRule')->twice()->andReturn('Firewall rule applied.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->patchJson("/v5/resource-connections/{$connectionId}", [
|
||||
'ports_by_direction' => [
|
||||
"{$source->uuid}->{$target->uuid}" => [80],
|
||||
"{$target->uuid}->{$source->uuid}" => [443],
|
||||
],
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonPath("connection.portsByDirection.{$source->uuid}->{$target->uuid}.0", '80')
|
||||
->assertJsonPath("connection.portsByDirection.{$target->uuid}->{$source->uuid}.0", '443');
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession([
|
||||
'currentTeam' => $team,
|
||||
'v5.selectedProjectUuid' => $project->uuid,
|
||||
'v5.selectedEnvironmentUuid' => $environment->uuid,
|
||||
])
|
||||
->get('/v5')
|
||||
->assertSuccessful()
|
||||
->assertSee('"resourceConnections":[', false)
|
||||
->assertSee("\"id\":\"{$connectionId}\"", false)
|
||||
->assertSee("\"{$source->uuid}->{$target->uuid}\":[\"80\"]", false)
|
||||
->assertSee("\"{$target->uuid}->{$source->uuid}\":[\"443\"]", false);
|
||||
});
|
||||
|
||||
it('reports flux failures when syncing v5 resource connection firewall rules', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
createSharedUserAndTeamTables();
|
||||
Exceptions::fake();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
$source = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'api',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-api',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$target = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'postgres',
|
||||
'image' => 'docker.io/library/postgres:16',
|
||||
'container_name' => 'coolify-v5-postgres',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
$connection = ResourceConnection::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'resource_one_type' => $source->getMorphClass(),
|
||||
'resource_one_id' => $source->id,
|
||||
'resource_two_type' => $target->getMorphClass(),
|
||||
'resource_two_id' => $target->id,
|
||||
'resource_pair_key' => "application:{$source->id}|application:{$target->id}",
|
||||
'created_by_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('resolve firewall endpoint coolify-v5-api on coolify-default-mesh'));
|
||||
// The compensation pass rolls the node back by revoking the attempted rule.
|
||||
$fluxClient
|
||||
->shouldReceive('revokeFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:5432")
|
||||
->andReturn('Firewall rule removed.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->patchJson("/v5/resource-connections/{$connection->uuid}", [
|
||||
'ports_by_direction' => [
|
||||
"{$source->uuid}->{$target->uuid}" => [5432],
|
||||
],
|
||||
])
|
||||
->assertStatus(502)
|
||||
->assertJsonPath('message', 'Could not sync firewall rules through Flux.')
|
||||
->assertJsonPath('detail', 'The previous rules were restored. Check the server diagnostics and try again.');
|
||||
|
||||
expect($connection->rules()->count())->toBe(0);
|
||||
|
||||
// The raw Flux/coold error is never leaked to the client, only reported server-side.
|
||||
Exceptions::assertReported(fn (RuntimeException $exception): bool => $exception->getMessage() === 'resolve firewall endpoint coolify-v5-api on coolify-default-mesh');
|
||||
});
|
||||
|
||||
it('syncs cross-server v5 resource connection ports on both endpoint hosts', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
]);
|
||||
$sourceServer = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
$targetServer = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-02',
|
||||
'host' => '203.0.113.11',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.11',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
$source = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $sourceServer->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'api',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-api',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$target = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $targetServer->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'postgres',
|
||||
'image' => 'docker.io/library/postgres:16',
|
||||
'container_name' => 'coolify-v5-postgres',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
$connection = ResourceConnection::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'resource_one_type' => $source->getMorphClass(),
|
||||
'resource_one_id' => $source->id,
|
||||
'resource_two_type' => $target->getMorphClass(),
|
||||
'resource_two_id' => $target->id,
|
||||
'resource_pair_key' => "application:{$source->id}|application:{$target->id}",
|
||||
'created_by_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$firewallRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:5432";
|
||||
$expectedRule = [
|
||||
'id' => $firewallRuleId,
|
||||
'namespace' => 'default',
|
||||
'src' => 'coolify-v5-api',
|
||||
'dst' => 'coolify-v5-postgres',
|
||||
'proto' => 'tcp',
|
||||
'port' => 5432,
|
||||
];
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), $expectedRule)
|
||||
->andReturn('Firewall rule applied on source host.');
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), $expectedRule)
|
||||
->andReturn('Firewall rule applied on target host.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->patchJson("/v5/resource-connections/{$connection->uuid}", [
|
||||
'ports_by_direction' => [
|
||||
"{$source->uuid}->{$target->uuid}" => [5432],
|
||||
],
|
||||
])
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('syncs v5 resource connection ports through flux firewall primitives', function () {
|
||||
app()->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->withoutVite();
|
||||
$this->withoutExceptionHandling();
|
||||
createSharedUserAndTeamTables();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$cluster = Cluster::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'Production Mesh',
|
||||
]);
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
$source = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'api',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-api',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$target = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'postgres',
|
||||
'image' => 'docker.io/library/postgres:16',
|
||||
'container_name' => 'coolify-v5-postgres',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
$connection = ResourceConnection::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'resource_one_type' => $source->getMorphClass(),
|
||||
'resource_one_id' => $source->id,
|
||||
'resource_two_type' => $target->getMorphClass(),
|
||||
'resource_two_id' => $target->id,
|
||||
'resource_pair_key' => "application:{$source->id}|application:{$target->id}",
|
||||
'created_by_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$firewallRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:5432";
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), [
|
||||
'id' => $firewallRuleId,
|
||||
'namespace' => 'default',
|
||||
'src' => 'coolify-v5-api',
|
||||
'dst' => 'coolify-v5-postgres',
|
||||
'proto' => 'tcp',
|
||||
'port' => 5432,
|
||||
])
|
||||
->andReturn('rule-api-postgres');
|
||||
$fluxClient
|
||||
->shouldReceive('revokeFirewallRule')
|
||||
->once()
|
||||
->with(Mockery::type('string'), $firewallRuleId)
|
||||
->andReturn('Firewall rule removed.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->patchJson("/v5/resource-connections/{$connection->uuid}", [
|
||||
'ports_by_direction' => [
|
||||
"{$source->uuid}->{$target->uuid}" => [5432],
|
||||
],
|
||||
])
|
||||
->assertSuccessful();
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->deleteJson("/v5/resource-connections/{$connection->uuid}")
|
||||
->assertNoContent();
|
||||
});
|
||||
|
||||
it('rejects firewall sync when a connected application has no server host id', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Exceptions::fake();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
$source = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'api',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-api',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$target = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'postgres',
|
||||
'image' => 'docker.io/library/postgres:16',
|
||||
'container_name' => 'coolify-v5-postgres',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$connection = ResourceConnection::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'resource_one_type' => $source->getMorphClass(),
|
||||
'resource_one_id' => $source->id,
|
||||
'resource_two_type' => $target->getMorphClass(),
|
||||
'resource_two_id' => $target->id,
|
||||
'resource_pair_key' => "application:{$source->id}|application:{$target->id}",
|
||||
'created_by_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->patchJson("/v5/resource-connections/{$connection->uuid}", [
|
||||
'ports_by_direction' => [
|
||||
"{$source->uuid}->{$target->uuid}" => [5432],
|
||||
],
|
||||
])
|
||||
->assertStatus(502)
|
||||
->assertJsonPath('detail', 'The previous rules were restored. Check the server diagnostics and try again.');
|
||||
|
||||
expect($connection->rules()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('still deletes a v5 resource connection whose endpoint lost its server host id', function () {
|
||||
createSharedUserAndTeamTables();
|
||||
Exceptions::fake();
|
||||
|
||||
[$user, $team] = createV5UserWithTeam();
|
||||
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Production Project', 'Production');
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'capabilities' => [],
|
||||
]);
|
||||
$source = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'api',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-api',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$target = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'postgres',
|
||||
'image' => 'docker.io/library/postgres:16',
|
||||
'container_name' => 'coolify-v5-postgres',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$connection = ResourceConnection::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'resource_one_type' => $source->getMorphClass(),
|
||||
'resource_one_id' => $source->id,
|
||||
'resource_two_type' => $target->getMorphClass(),
|
||||
'resource_two_id' => $target->id,
|
||||
'resource_pair_key' => "application:{$source->id}|application:{$target->id}",
|
||||
'created_by_user_id' => $user->id,
|
||||
]);
|
||||
$connection->rules()->create([
|
||||
'source_resource_type' => $source->getMorphClass(),
|
||||
'source_resource_id' => $source->id,
|
||||
'target_resource_type' => $target->getMorphClass(),
|
||||
'target_resource_id' => $target->id,
|
||||
'protocol' => 'tcp',
|
||||
'port' => 5432,
|
||||
]);
|
||||
|
||||
$this
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->deleteJson("/v5/resource-connections/{$connection->uuid}")
|
||||
->assertNoContent();
|
||||
|
||||
expect(ResourceConnection::query()->whereKey($connection->id)->exists())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
<?php
|
||||
|
||||
use App\Exceptions\V5\UnsupportedCooldVerb;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Exceptions;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\Support\V5TestSchema;
|
||||
|
||||
beforeEach(function () {
|
||||
Config::set('broadcasting.default', 'log');
|
||||
Config::set('cache.default', 'array');
|
||||
|
||||
dropConnectionFirewallConsistencyTables();
|
||||
createConnectionFirewallConsistencyTables();
|
||||
});
|
||||
|
||||
it('restores db rules and rolls back node firewall when flux fails after the db write', function () {
|
||||
Exceptions::fake();
|
||||
|
||||
$context = createConnectionFirewallConsistencyContext();
|
||||
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
||||
|
||||
$connection->rules()->create([
|
||||
'source_resource_type' => $source->getMorphClass(),
|
||||
'source_resource_id' => $source->id,
|
||||
'target_resource_type' => $target->getMorphClass(),
|
||||
'target_resource_id' => $target->id,
|
||||
'protocol' => 'tcp',
|
||||
'port' => 5432,
|
||||
]);
|
||||
|
||||
$oldRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:5432";
|
||||
$newRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:6379";
|
||||
$oldRule = connectionFirewallConsistencyRule($oldRuleId, 5432);
|
||||
$newRule = connectionFirewallConsistencyRule($newRuleId, 6379);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
// Forward sync: revoke the removed old rule, then fail applying the new one.
|
||||
$fluxClient->shouldReceive('revokeFirewallRule')->once()->with(Mockery::type('string'), $oldRuleId)->andReturn('Firewall rule removed.');
|
||||
$fluxClient->shouldReceive('applyFirewallRule')->once()->with(Mockery::type('string'), $newRule)->andThrow(new RuntimeException('apply firewall rule: nft exited with status 1'));
|
||||
// Compensation: revoke the attempted new rule and re-apply the restored old rule.
|
||||
$fluxClient->shouldReceive('revokeFirewallRule')->once()->with(Mockery::type('string'), $newRuleId)->andReturn('Firewall rule removed.');
|
||||
$fluxClient->shouldReceive('applyFirewallRule')->once()->with(Mockery::type('string'), $oldRule)->andReturn('Firewall rule applied.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
patchConnectionFirewallConsistency($this, $user, $team, $connection, [
|
||||
"{$source->uuid}->{$target->uuid}" => [6379],
|
||||
])
|
||||
->assertStatus(502)
|
||||
->assertJsonPath('message', 'Could not sync firewall rules through Flux.')
|
||||
->assertJsonPath('detail', 'The previous rules were restored. Check the server diagnostics and try again.');
|
||||
|
||||
expect($connection->rules()->pluck('port')->all())->toBe([5432]);
|
||||
|
||||
Exceptions::assertReported(fn (RuntimeException $exception): bool => $exception->getMessage() === 'apply firewall rule: nft exited with status 1');
|
||||
});
|
||||
|
||||
it('keeps committed db rules and succeeds when the node coold lacks firewall support', function () {
|
||||
$context = createConnectionFirewallConsistencyContext();
|
||||
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
||||
|
||||
$connection->rules()->create([
|
||||
'source_resource_type' => $source->getMorphClass(),
|
||||
'source_resource_id' => $source->id,
|
||||
'target_resource_type' => $target->getMorphClass(),
|
||||
'target_resource_id' => $target->id,
|
||||
'protocol' => 'tcp',
|
||||
'port' => 5432,
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('revokeFirewallRule')->once()->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host'));
|
||||
$fluxClient->shouldReceive('applyFirewallRule')->once()->andThrow(new UnsupportedCooldVerb('firewall.allow', 'primitive firewall.allow is not supported by host'));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
patchConnectionFirewallConsistency($this, $user, $team, $connection, [
|
||||
"{$source->uuid}->{$target->uuid}" => [6379],
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonPath("connection.portsByDirection.{$source->uuid}->{$target->uuid}.0", '6379');
|
||||
|
||||
expect($connection->rules()->pluck('port')->all())->toBe([6379]);
|
||||
});
|
||||
|
||||
it('still surfaces the original flux error when the firewall rollback also fails', function () {
|
||||
Exceptions::fake();
|
||||
Log::spy();
|
||||
|
||||
$context = createConnectionFirewallConsistencyContext();
|
||||
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
||||
|
||||
$connection->rules()->create([
|
||||
'source_resource_type' => $source->getMorphClass(),
|
||||
'source_resource_id' => $source->id,
|
||||
'target_resource_type' => $target->getMorphClass(),
|
||||
'target_resource_id' => $target->id,
|
||||
'protocol' => 'tcp',
|
||||
'port' => 5432,
|
||||
]);
|
||||
|
||||
$oldRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:5432";
|
||||
$newRuleId = "v5-resource-connection:{$connection->id}:{$source->id}:{$target->id}:tcp:6379";
|
||||
$newRule = connectionFirewallConsistencyRule($newRuleId, 6379);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('revokeFirewallRule')->once()->with(Mockery::type('string'), $oldRuleId)->andReturn('Firewall rule removed.');
|
||||
$fluxClient->shouldReceive('applyFirewallRule')->once()->with(Mockery::type('string'), $newRule)->andThrow(new RuntimeException('apply firewall rule: nft exited with status 1'));
|
||||
$fluxClient->shouldReceive('revokeFirewallRule')->once()->with(Mockery::type('string'), $newRuleId)->andThrow(new RuntimeException('revoke firewall rule: coold connection reset'));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
patchConnectionFirewallConsistency($this, $user, $team, $connection, [
|
||||
"{$source->uuid}->{$target->uuid}" => [6379],
|
||||
])
|
||||
->assertStatus(502)
|
||||
->assertJsonPath('detail', 'The previous rules were restored. Check the server diagnostics and try again.');
|
||||
|
||||
expect($connection->rules()->pluck('port')->all())->toBe([5432]);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn (string $message): bool => str_contains($message, 'firewall rollback failed'))
|
||||
->once();
|
||||
});
|
||||
|
||||
it('does not delete the connection when revoking firewall rules fails with a real error', function () {
|
||||
Exceptions::fake();
|
||||
|
||||
$context = createConnectionFirewallConsistencyContext();
|
||||
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
||||
|
||||
$connection->rules()->create([
|
||||
'source_resource_type' => $source->getMorphClass(),
|
||||
'source_resource_id' => $source->id,
|
||||
'target_resource_type' => $target->getMorphClass(),
|
||||
'target_resource_id' => $target->id,
|
||||
'protocol' => 'tcp',
|
||||
'port' => 5432,
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('revokeFirewallRule')->once()->andThrow(new RuntimeException('revoke firewall rule: nft exited with status 1'));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
deleteConnectionFirewallConsistency($this, $user, $team, $connection)
|
||||
->assertStatus(502)
|
||||
->assertJsonPath('detail', 'The connection was not deleted. Check the server diagnostics and try again.');
|
||||
|
||||
expect(ResourceConnection::query()->whereKey($connection->id)->exists())->toBeTrue()
|
||||
->and($connection->rules()->count())->toBe(1);
|
||||
|
||||
Exceptions::assertReported(fn (RuntimeException $exception): bool => $exception->getMessage() === 'revoke firewall rule: nft exited with status 1');
|
||||
});
|
||||
|
||||
it('reports but tolerates an unbuildable firewall snapshot when deleting the connection', function () {
|
||||
Exceptions::fake();
|
||||
|
||||
$context = createConnectionFirewallConsistencyContext(sourceServerless: true);
|
||||
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
||||
|
||||
$connection->rules()->create([
|
||||
'source_resource_type' => $source->getMorphClass(),
|
||||
'source_resource_id' => $source->id,
|
||||
'target_resource_type' => $target->getMorphClass(),
|
||||
'target_resource_id' => $target->id,
|
||||
'protocol' => 'tcp',
|
||||
'port' => 5432,
|
||||
]);
|
||||
|
||||
// The node cannot be addressed without a host id, so no flux call may happen.
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
deleteConnectionFirewallConsistency($this, $user, $team, $connection)
|
||||
->assertNoContent();
|
||||
|
||||
expect(ResourceConnection::query()->whereKey($connection->id)->exists())->toBeFalse();
|
||||
|
||||
Exceptions::assertReported(fn (RuntimeException $exception): bool => str_contains($exception->getMessage(), 'has no reachable server host id'));
|
||||
});
|
||||
|
||||
it('deletes the connection when the node coold lacks firewall revoke support', function () {
|
||||
$context = createConnectionFirewallConsistencyContext();
|
||||
['user' => $user, 'team' => $team, 'source' => $source, 'target' => $target, 'connection' => $connection] = $context;
|
||||
|
||||
$connection->rules()->create([
|
||||
'source_resource_type' => $source->getMorphClass(),
|
||||
'source_resource_id' => $source->id,
|
||||
'target_resource_type' => $target->getMorphClass(),
|
||||
'target_resource_id' => $target->id,
|
||||
'protocol' => 'tcp',
|
||||
'port' => 5432,
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldReceive('revokeFirewallRule')->once()->andThrow(new UnsupportedCooldVerb('firewall.revoke', 'primitive firewall.revoke is not supported by host'));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
deleteConnectionFirewallConsistency($this, $user, $team, $connection)
|
||||
->assertNoContent();
|
||||
|
||||
expect(ResourceConnection::query()->whereKey($connection->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<string, array<int, int>> $portsByDirection
|
||||
*/
|
||||
function patchConnectionFirewallConsistency(mixed $test, User $user, Team $team, ResourceConnection $connection, array $portsByDirection): mixed
|
||||
{
|
||||
return $test
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->patchJson("/v5/resource-connections/{$connection->uuid}", [
|
||||
'ports_by_direction' => $portsByDirection,
|
||||
]);
|
||||
}
|
||||
|
||||
function deleteConnectionFirewallConsistency(mixed $test, User $user, Team $team, ResourceConnection $connection): mixed
|
||||
{
|
||||
return $test
|
||||
->actingAs($user)
|
||||
->withSession(['currentTeam' => $team, '_token' => 'test-csrf-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-csrf-token')
|
||||
->deleteJson("/v5/resource-connections/{$connection->uuid}");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}
|
||||
*/
|
||||
function connectionFirewallConsistencyRule(string $ruleId, int $port): array
|
||||
{
|
||||
return [
|
||||
'id' => $ruleId,
|
||||
'namespace' => 'default',
|
||||
'src' => 'coolify-v5-consistency-api',
|
||||
'dst' => 'coolify-v5-consistency-postgres',
|
||||
'proto' => 'tcp',
|
||||
'port' => $port,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{user: User, team: Team, source: V5Application, target: V5Application, connection: ResourceConnection}
|
||||
*/
|
||||
function createConnectionFirewallConsistencyContext(bool $sourceServerless = false): array
|
||||
{
|
||||
$user = User::withoutEvents(fn () => User::query()->create([
|
||||
'name' => 'Margaret Hamilton',
|
||||
'email' => 'margaret@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
]));
|
||||
$team = Team::withoutEvents(fn () => Team::query()->create([
|
||||
'name' => 'V5 Firewall Team',
|
||||
'description' => null,
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
]));
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
|
||||
$server = V5Server::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'edge-01',
|
||||
'host' => '203.0.113.10',
|
||||
'ssh_user' => 'root',
|
||||
'ssh_port' => 22,
|
||||
'status' => 'installed',
|
||||
'wireguard_management_ip' => '100.64.0.10',
|
||||
]);
|
||||
|
||||
$source = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => 1,
|
||||
'environment_id' => 1,
|
||||
'server_id' => $sourceServerless ? null : $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'api',
|
||||
'image' => 'docker.io/library/nginx:alpine',
|
||||
'container_name' => 'coolify-v5-consistency-api',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'running',
|
||||
]);
|
||||
$target = V5Application::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => 1,
|
||||
'environment_id' => 1,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $user->id,
|
||||
'name' => 'postgres',
|
||||
'image' => 'docker.io/library/postgres:16',
|
||||
'container_name' => 'coolify-v5-consistency-postgres',
|
||||
'mesh_namespace' => 'default',
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
$connection = ResourceConnection::query()->create([
|
||||
'team_id' => $team->id,
|
||||
'project_id' => 1,
|
||||
'environment_id' => 1,
|
||||
'resource_one_type' => $source->getMorphClass(),
|
||||
'resource_one_id' => $source->id,
|
||||
'resource_two_type' => $target->getMorphClass(),
|
||||
'resource_two_id' => $target->id,
|
||||
'resource_pair_key' => "application:{$source->id}|application:{$target->id}",
|
||||
'created_by_user_id' => $user->id,
|
||||
]);
|
||||
|
||||
return [
|
||||
'user' => $user,
|
||||
'team' => $team,
|
||||
'source' => $source,
|
||||
'target' => $target,
|
||||
'connection' => $connection,
|
||||
];
|
||||
}
|
||||
|
||||
function createConnectionFirewallConsistencyTables(): void
|
||||
{
|
||||
Schema::create('users', function ($table) {
|
||||
$table->id();
|
||||
$table->string('name')->default('Anonymous');
|
||||
$table->string('email');
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('teams', function ($table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('description')->nullable();
|
||||
$table->boolean('personal_team')->default(false);
|
||||
$table->boolean('show_boarding')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('team_user', function ($table) {
|
||||
$table->id();
|
||||
$table->foreignId('team_id');
|
||||
$table->foreignId('user_id');
|
||||
$table->string('role')->default('member');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['team_id', 'user_id']);
|
||||
});
|
||||
|
||||
V5TestSchema::createServersTable();
|
||||
V5TestSchema::createApplicationsTable();
|
||||
V5TestSchema::createResourceConnectionsTable();
|
||||
V5TestSchema::createResourceConnectionRulesTable();
|
||||
}
|
||||
|
||||
function dropConnectionFirewallConsistencyTables(): void
|
||||
{
|
||||
Schema::dropIfExists('v5_resource_connection_rules');
|
||||
Schema::dropIfExists('v5_resource_connections');
|
||||
Schema::dropIfExists('v5_applications');
|
||||
Schema::dropIfExists('v5_servers');
|
||||
Schema::dropIfExists('team_user');
|
||||
Schema::dropIfExists('teams');
|
||||
Schema::dropIfExists('users');
|
||||
}
|
||||
Loaded 100 of 237 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user