feat(v5): sync server status and ingress access
Add Flux agent token issuing, open and revoke Caddy ingress firewall rules, broadcast server-scoped application status updates, and update the dashboard to show unreachable servers as unknown.
This commit is contained in:
parent
75cbcad240
commit
e3e91b7741
14 changed files with 557 additions and 47 deletions
|
|
@ -12,6 +12,8 @@ class StartCaddyIngress
|
||||||
{
|
{
|
||||||
use AsAction;
|
use AsAction;
|
||||||
|
|
||||||
|
private const FIREWALL_RULE_ID = 'v5-caddy-ingress:80';
|
||||||
|
|
||||||
public function __construct(private readonly FluxClient $fluxClient) {}
|
public function __construct(private readonly FluxClient $fluxClient) {}
|
||||||
|
|
||||||
public function handle(Server $server): string
|
public function handle(Server $server): string
|
||||||
|
|
@ -28,6 +30,14 @@ public function handle(Server $server): string
|
||||||
|
|
||||||
$configuration = GenerateCaddyIngressConfiguration::run($this->applications($server));
|
$configuration = GenerateCaddyIngressConfiguration::run($this->applications($server));
|
||||||
$output = $this->fluxClient->applyIngress($hostId, 'caddy', $configuration['caddyfile'], $this->ingressApps($configuration['apps']));
|
$output = $this->fluxClient->applyIngress($hostId, 'caddy', $configuration['caddyfile'], $this->ingressApps($configuration['apps']));
|
||||||
|
$this->fluxClient->applyFirewallRule($hostId, [
|
||||||
|
'id' => self::FIREWALL_RULE_ID,
|
||||||
|
'namespace' => 'default',
|
||||||
|
'src' => '0.0.0.0/0',
|
||||||
|
'dst' => 'coolify-v5-caddy',
|
||||||
|
'proto' => 'tcp',
|
||||||
|
'port' => 80,
|
||||||
|
]);
|
||||||
|
|
||||||
if ($server->exists) {
|
if ($server->exists) {
|
||||||
$server->update([
|
$server->update([
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ class StopCaddyIngress
|
||||||
{
|
{
|
||||||
use AsAction;
|
use AsAction;
|
||||||
|
|
||||||
|
private const FIREWALL_RULE_ID = 'v5-caddy-ingress:80';
|
||||||
|
|
||||||
public function __construct(private readonly FluxClient $fluxClient) {}
|
public function __construct(private readonly FluxClient $fluxClient) {}
|
||||||
|
|
||||||
public function handle(Server $server): string
|
public function handle(Server $server): string
|
||||||
|
|
@ -21,6 +23,7 @@ public function handle(Server $server): string
|
||||||
}
|
}
|
||||||
|
|
||||||
$output = $this->fluxClient->stopIngress($hostId, 'caddy');
|
$output = $this->fluxClient->stopIngress($hostId, 'caddy');
|
||||||
|
$this->fluxClient->revokeFirewallRule($hostId, self::FIREWALL_RULE_ID);
|
||||||
|
|
||||||
if ($server->exists) {
|
if ($server->exists) {
|
||||||
$server->update(['ingress_status' => 'exited']);
|
$server->update(['ingress_status' => 'exited']);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use Firebase\JWT\JWT;
|
use App\Services\Flux\AgentTokenIssuer;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
@ -28,7 +28,7 @@ private function defaultCapabilities(): array
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(AgentTokenIssuer $agentTokenIssuer): int
|
||||||
{
|
{
|
||||||
if (! app()->environment(['local', 'development', 'testing']) && ! $this->option('force')) {
|
if (! app()->environment(['local', 'development', 'testing']) && ! $this->option('force')) {
|
||||||
$this->error('This command is intended for development only. Use --force to override.');
|
$this->error('This command is intended for development only. Use --force to override.');
|
||||||
|
|
@ -36,17 +36,8 @@ public function handle(): int
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$privateKeyPath = config('flux.jwt_private_key_path');
|
|
||||||
|
|
||||||
if (! is_string($privateKeyPath) || $privateKeyPath === '' || ! File::isReadable($privateKeyPath)) {
|
|
||||||
$this->error("Flux JWT private key not found at {$privateKeyPath}.");
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hostId = (string) $this->argument('host_id');
|
$hostId = (string) $this->argument('host_id');
|
||||||
$ttl = max(60, (int) $this->option('ttl'));
|
$ttl = max(60, (int) $this->option('ttl'));
|
||||||
$now = time();
|
|
||||||
$caps = collect(explode(',', (string) $this->option('caps')))
|
$caps = collect(explode(',', (string) $this->option('caps')))
|
||||||
->map(fn (string $cap) => trim($cap))
|
->map(fn (string $cap) => trim($cap))
|
||||||
->filter()
|
->filter()
|
||||||
|
|
@ -58,13 +49,13 @@ public function handle(): int
|
||||||
$caps = $this->defaultCapabilities();
|
$caps = $this->defaultCapabilities();
|
||||||
}
|
}
|
||||||
|
|
||||||
$token = JWT::encode([
|
try {
|
||||||
'sub' => $hostId,
|
$token = $agentTokenIssuer->issue($hostId, $caps, $ttl);
|
||||||
'aud' => 'coold',
|
} catch (\RuntimeException $exception) {
|
||||||
'caps' => $caps,
|
$this->error($exception->getMessage());
|
||||||
'iat' => $now,
|
|
||||||
'exp' => $now + $ttl,
|
return self::FAILURE;
|
||||||
], File::get($privateKeyPath), 'ES256');
|
}
|
||||||
|
|
||||||
$output = $this->option('output');
|
$output = $this->option('output');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ public function __construct(
|
||||||
public int $teamId,
|
public int $teamId,
|
||||||
public ?int $applicationId = null,
|
public ?int $applicationId = null,
|
||||||
public ?int $caddyIngressServerId = null,
|
public ?int $caddyIngressServerId = null,
|
||||||
|
public ?int $serverId = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function broadcastOn(): array
|
public function broadcastOn(): array
|
||||||
|
|
@ -33,19 +34,29 @@ public function broadcastAs(): string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{application: array<string, mixed>|null, caddyIngress: array<string, mixed>|null}
|
* @return array{application: array<string, mixed>|null, applications: array<int, array<string, mixed>>, caddyIngress: array<string, mixed>|null}
|
||||||
*/
|
*/
|
||||||
public function broadcastWith(): array
|
public function broadcastWith(): array
|
||||||
{
|
{
|
||||||
$application = $this->applicationId !== null
|
$application = $this->applicationId !== null
|
||||||
? V5Application::query()->with('server')->find($this->applicationId)
|
? V5Application::query()->with(['server', 'domains'])->find($this->applicationId)
|
||||||
: null;
|
: null;
|
||||||
|
$applications = $this->serverId !== null
|
||||||
|
? V5Application::query()
|
||||||
|
->where('server_id', $this->serverId)
|
||||||
|
->with(['server', 'domains'])
|
||||||
|
->get()
|
||||||
|
: collect();
|
||||||
$caddyIngress = $this->caddyIngressServerId !== null
|
$caddyIngress = $this->caddyIngressServerId !== null
|
||||||
? V5Server::query()->find($this->caddyIngressServerId)
|
? V5Server::query()->find($this->caddyIngressServerId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'application' => $application instanceof V5Application ? $this->serializeApplication($application) : null,
|
'application' => $application instanceof V5Application ? $this->serializeApplication($application) : null,
|
||||||
|
'applications' => $applications
|
||||||
|
->map(fn (V5Application $application) => $this->serializeApplication($application))
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
'caddyIngress' => $caddyIngress instanceof V5Server && $caddyIngress->isIngress()
|
'caddyIngress' => $caddyIngress instanceof V5Server && $caddyIngress->isIngress()
|
||||||
? $this->serializeCaddyIngress($caddyIngress)
|
? $this->serializeCaddyIngress($caddyIngress)
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -57,6 +68,9 @@ public function broadcastWith(): array
|
||||||
*/
|
*/
|
||||||
private function serializeApplication(V5Application $application): array
|
private function serializeApplication(V5Application $application): array
|
||||||
{
|
{
|
||||||
|
$server = $application->server;
|
||||||
|
$isServerReachable = ! $server instanceof V5Server || $this->isServerReachable($server);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => (string) $application->id,
|
'id' => (string) $application->id,
|
||||||
'name' => $application->name,
|
'name' => $application->name,
|
||||||
|
|
@ -64,26 +78,50 @@ private function serializeApplication(V5Application $application): array
|
||||||
'containerName' => $application->container_name,
|
'containerName' => $application->container_name,
|
||||||
'status' => $application->status,
|
'status' => $application->status,
|
||||||
'statusMessage' => $application->status_message,
|
'statusMessage' => $application->status_message,
|
||||||
|
'effectiveStatus' => $isServerReachable ? $application->status : 'unknown',
|
||||||
|
'effectiveStatusMessage' => $isServerReachable
|
||||||
|
? $application->status_message
|
||||||
|
: $this->serverStatusMessage($server),
|
||||||
'runtimeContainerId' => $application->runtime_container_id,
|
'runtimeContainerId' => $application->runtime_container_id,
|
||||||
'serverName' => $application->server?->name,
|
'serverName' => $server?->name,
|
||||||
|
'serverStatus' => $server?->status,
|
||||||
|
'serverStatusMessage' => $server instanceof V5Server ? $this->serverStatusMessage($server) : null,
|
||||||
|
'isServerReachable' => $isServerReachable,
|
||||||
|
'serverIngressEnabled' => (bool) $server?->isIngress(),
|
||||||
'meshNamespace' => $application->mesh_namespace,
|
'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',
|
'meshFqdn' => $application->container_name.'.'.($application->mesh_namespace ?: 'default').'.coolify.internal',
|
||||||
'canvasX' => $application->canvas_x,
|
'canvasX' => $application->canvas_x,
|
||||||
'canvasY' => $application->canvas_y,
|
'canvasY' => $application->canvas_y,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isServerReachable(V5Server $server): bool
|
||||||
|
{
|
||||||
|
return $server->status !== 'unreachable';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serverStatusMessage(?V5Server $server): ?string
|
||||||
|
{
|
||||||
|
return $server?->last_status_output ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private function serializeCaddyIngress(V5Server $server): array
|
private function serializeCaddyIngress(V5Server $server): array
|
||||||
{
|
{
|
||||||
|
$isServerReachable = $this->isServerReachable($server);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => (string) $server->id,
|
'id' => (string) $server->id,
|
||||||
'name' => $server->name,
|
'name' => $server->name,
|
||||||
'host' => $server->host,
|
'host' => $server->host,
|
||||||
'type' => $server->ingressType(),
|
'type' => $server->ingressType(),
|
||||||
'status' => $server->ingressStatus(),
|
'status' => $isServerReachable ? $server->ingressStatus() : 'unreachable',
|
||||||
|
'statusMessage' => $isServerReachable ? null : $this->serverStatusMessage($server),
|
||||||
'canvasX' => $server->canvas_x ?? -352,
|
'canvasX' => $server->canvas_x ?? -352,
|
||||||
'canvasY' => $server->canvas_y ?? 0,
|
'canvasY' => $server->canvas_y ?? 0,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1592,7 +1592,7 @@ private function serializeApplication(V5Application $application): array
|
||||||
'containerName' => $application->container_name,
|
'containerName' => $application->container_name,
|
||||||
'status' => $application->status,
|
'status' => $application->status,
|
||||||
'statusMessage' => $application->status_message,
|
'statusMessage' => $application->status_message,
|
||||||
'effectiveStatus' => $isServerReachable ? $application->status : 'unreachable',
|
'effectiveStatus' => $isServerReachable ? $application->status : 'unknown',
|
||||||
'effectiveStatusMessage' => $isServerReachable
|
'effectiveStatusMessage' => $isServerReachable
|
||||||
? $application->status_message
|
? $application->status_message
|
||||||
: $this->serverStatusMessage($server),
|
: $this->serverStatusMessage($server),
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,17 @@ protected static function booted(): void
|
||||||
V5ClusterUpdated::dispatch($server->team_id, $server->cluster_id);
|
V5ClusterUpdated::dispatch($server->team_id, $server->cluster_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($server->wasChanged('status')) {
|
||||||
|
V5CanvasResourceUpdated::dispatch(
|
||||||
|
$server->team_id,
|
||||||
|
null,
|
||||||
|
$server->isIngress() ? $server->id : null,
|
||||||
|
$server->id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($server->isIngress()) {
|
if ($server->isIngress()) {
|
||||||
V5CanvasResourceUpdated::dispatch($server->team_id, null, $server->id);
|
V5CanvasResourceUpdated::dispatch($server->team_id, null, $server->id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
75
app/Services/Flux/AgentTokenIssuer.php
Normal file
75
app/Services/Flux/AgentTokenIssuer.php
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Flux;
|
||||||
|
|
||||||
|
use App\Models\V5\Server as V5Server;
|
||||||
|
use Firebase\JWT\JWT;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class AgentTokenIssuer
|
||||||
|
{
|
||||||
|
public const DEFAULT_PROFILE = 'host-agent:default';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $capabilities
|
||||||
|
* @param array<string, mixed> $extraClaims
|
||||||
|
*/
|
||||||
|
public function issue(string $hostId, array $capabilities = [self::DEFAULT_PROFILE], int $ttl = 86400, 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}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
return JWT::encode(array_merge($extraClaims, [
|
||||||
|
'sub' => $hostId,
|
||||||
|
'aud' => 'coold',
|
||||||
|
'caps' => $this->normalizeCapabilities($capabilities),
|
||||||
|
'iat' => $now,
|
||||||
|
'exp' => $now + max(60, $ttl),
|
||||||
|
]), File::get($privateKeyPath), 'ES256');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function issueForServer(V5Server $server, int $ttl = 86400): string
|
||||||
|
{
|
||||||
|
$hostId = $server->wireguard_management_ip ?: $server->node_address;
|
||||||
|
|
||||||
|
if (! is_string($hostId) || $hostId === '') {
|
||||||
|
throw new RuntimeException('Server is missing its Flux host id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->issue($hostId, [self::DEFAULT_PROFILE], $ttl, [
|
||||||
|
'team_id' => $server->team_id,
|
||||||
|
'cluster_id' => $server->cluster_id,
|
||||||
|
'server_id' => $server->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ type ConnectionEndpoint = {
|
||||||
|
|
||||||
type V5CanvasResourceUpdatedEvent = {
|
type V5CanvasResourceUpdatedEvent = {
|
||||||
application: V5Application | null;
|
application: V5Application | null;
|
||||||
|
applications?: V5Application[];
|
||||||
caddyIngress: V5CaddyIngress | null;
|
caddyIngress: V5CaddyIngress | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -117,6 +118,10 @@ function statusBadgeClass(status: string): string | false {
|
||||||
return 'bg-warning/15 text-warning';
|
return 'bg-warning/15 text-warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status === 'unknown') {
|
||||||
|
return 'bg-muted text-muted-foreground';
|
||||||
|
}
|
||||||
|
|
||||||
if (['failed', 'exited', 'unreachable'].includes(status)) {
|
if (['failed', 'exited', 'unreachable'].includes(status)) {
|
||||||
return 'bg-destructive/15 text-destructive';
|
return 'bg-destructive/15 text-destructive';
|
||||||
}
|
}
|
||||||
|
|
@ -185,6 +190,7 @@ export default function Dashboard({
|
||||||
const [ingressModal, setIngressModal] = useState<IngressModalState | null>(null);
|
const [ingressModal, setIngressModal] = useState<IngressModalState | null>(null);
|
||||||
const [isSavingIngress, setIsSavingIngress] = useState(false);
|
const [isSavingIngress, setIsSavingIngress] = useState(false);
|
||||||
const [savingIngressApplicationId, setSavingIngressApplicationId] = useState<string | null>(null);
|
const [savingIngressApplicationId, setSavingIngressApplicationId] = useState<string | null>(null);
|
||||||
|
const [deletingApplicationIds, setDeletingApplicationIds] = useState<Set<string>>(() => new Set());
|
||||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||||
const hasCanvasNodes = applications.length > 0 || ingresses.length > 0;
|
const hasCanvasNodes = applications.length > 0 || ingresses.length > 0;
|
||||||
|
|
||||||
|
|
@ -192,7 +198,7 @@ export default function Dashboard({
|
||||||
() => ({
|
() => ({
|
||||||
running: applications.filter((application) => application.effectiveStatus === 'running').length,
|
running: applications.filter((application) => application.effectiveStatus === 'running').length,
|
||||||
failed: applications.filter((application) => application.effectiveStatus === 'failed').length,
|
failed: applications.filter((application) => application.effectiveStatus === 'failed').length,
|
||||||
unreachable: applications.filter((application) => application.effectiveStatus === 'unreachable').length,
|
unknown: applications.filter((application) => application.effectiveStatus === 'unknown').length,
|
||||||
}),
|
}),
|
||||||
[applications],
|
[applications],
|
||||||
);
|
);
|
||||||
|
|
@ -259,6 +265,16 @@ export default function Dashboard({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.applications && event.applications.length > 0) {
|
||||||
|
setApplications((currentApplications) =>
|
||||||
|
currentApplications.map((application) => {
|
||||||
|
const updatedApplication = event.applications?.find((candidate) => candidate.id === application.id);
|
||||||
|
|
||||||
|
return updatedApplication ?? application;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (event.caddyIngress) {
|
if (event.caddyIngress) {
|
||||||
setIngresses((currentIngresses) =>
|
setIngresses((currentIngresses) =>
|
||||||
currentIngresses.map((ingress) =>
|
currentIngresses.map((ingress) =>
|
||||||
|
|
@ -660,6 +676,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
|
||||||
|
|
||||||
async function removeApplication(application: V5Application): Promise<void> {
|
async function removeApplication(application: V5Application): Promise<void> {
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
|
setDeletingApplicationIds((currentIds) => new Set(currentIds).add(application.id));
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -689,6 +706,14 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setNotice(error instanceof Error ? error.message : 'Could not delete application.');
|
setNotice(error instanceof Error ? error.message : 'Could not delete application.');
|
||||||
|
} finally {
|
||||||
|
setDeletingApplicationIds((currentIds) => {
|
||||||
|
const nextIds = new Set(currentIds);
|
||||||
|
|
||||||
|
nextIds.delete(application.id);
|
||||||
|
|
||||||
|
return nextIds;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1329,10 +1354,10 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
|
||||||
<span className="text-destructive">{statusCounts.failed} failed</span>
|
<span className="text-destructive">{statusCounts.failed} failed</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{statusCounts.unreachable > 0 && (
|
{statusCounts.unknown > 0 && (
|
||||||
<>
|
<>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span className="text-destructive">{statusCounts.unreachable} unreachable</span>
|
<span>{statusCounts.unknown} unknown</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1623,18 +1648,21 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{applications.map((application) => (
|
{applications.map((application) => {
|
||||||
<div
|
const isDeletingApplication = deletingApplicationIds.has(application.id);
|
||||||
key={application.id}
|
|
||||||
data-application-card="application-card"
|
return (
|
||||||
data-application-id={application.id}
|
<div
|
||||||
className="group/application absolute min-h-[8.5rem] w-80 select-none overflow-visible rounded-xl border border-border bg-card p-4 shadow-xl transition-shadow hover:shadow-2xl"
|
key={application.id}
|
||||||
style={{
|
data-application-card="application-card"
|
||||||
transform: `translate3d(${application.canvasX}px, ${application.canvasY}px, 0)`,
|
data-application-id={application.id}
|
||||||
}}
|
className="group/application absolute min-h-[8.5rem] w-80 select-none overflow-visible rounded-xl border border-border bg-card p-4 shadow-xl transition-shadow hover:shadow-2xl"
|
||||||
onPointerDown={(event) => startApplicationDrag(event, application)}
|
style={{
|
||||||
onDoubleClick={(event) => openApplicationInspector(event, application)}
|
transform: `translate3d(${application.canvasX}px, ${application.canvasY}px, 0)`,
|
||||||
>
|
}}
|
||||||
|
onPointerDown={(event) => startApplicationDrag(event, application)}
|
||||||
|
onDoubleClick={(event) => openApplicationInspector(event, application)}
|
||||||
|
>
|
||||||
{CONNECTOR_SIDES.map((side) => (
|
{CONNECTOR_SIDES.map((side) => (
|
||||||
<button
|
<button
|
||||||
key={side}
|
key={side}
|
||||||
|
|
@ -1687,9 +1715,10 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
void removeApplication(application);
|
void removeApplication(application);
|
||||||
}}
|
}}
|
||||||
className="rounded-md border border-destructive/40 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide text-destructive transition hover:bg-destructive/10"
|
disabled={isDeletingApplication}
|
||||||
|
className="rounded-md border border-destructive/40 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide text-destructive transition hover:bg-destructive/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
Delete
|
{isDeletingApplication ? 'Deleting…' : 'Delete'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1722,8 +1751,9 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ export type V5Application = {
|
||||||
containerName: string;
|
containerName: string;
|
||||||
status: 'creating' | 'running' | 'failed' | string;
|
status: 'creating' | 'running' | 'failed' | string;
|
||||||
statusMessage: string | null;
|
statusMessage: string | null;
|
||||||
effectiveStatus: 'creating' | 'running' | 'failed' | 'unreachable' | string;
|
effectiveStatus: 'creating' | 'running' | 'failed' | 'unknown' | string;
|
||||||
effectiveStatusMessage: string | null;
|
effectiveStatusMessage: string | null;
|
||||||
runtimeContainerId: string | null;
|
runtimeContainerId: string | null;
|
||||||
serverName: string | null;
|
serverName: string | null;
|
||||||
|
|
|
||||||
|
|
@ -456,6 +456,22 @@ coold_vm() {
|
||||||
scripts/coold-vm.sh "$@"
|
scripts/coold-vm.sh "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
coold_vm_shell() {
|
||||||
|
local instance="${1:-}"
|
||||||
|
|
||||||
|
if [ -z "$instance" ]; then
|
||||||
|
instance="$(coold_vm_instance 1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$instance" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "ERROR: Use the Lima hostname, not a numeric VM index." >&2
|
||||||
|
echo "Example: scripts/dev.sh shell $(coold_vm_instance 1)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
COOLIFY_COOLD_LIMA_INSTANCE="$instance" scripts/coold-vm.sh shell
|
||||||
|
}
|
||||||
|
|
||||||
coold_vm_up_with_retry() {
|
coold_vm_up_with_retry() {
|
||||||
local index="$1"
|
local index="$1"
|
||||||
local attempt
|
local attempt
|
||||||
|
|
@ -1117,7 +1133,8 @@ Commands:
|
||||||
down Stop the dev coold agent and Spin stack
|
down Stop the dev coold agent and Spin stack
|
||||||
down --cleanup
|
down --cleanup
|
||||||
Stop the dev stack, then delete the coold Lima VM(s) and VM-local state
|
Stop the dev stack, then delete the coold Lima VM(s) and VM-local state
|
||||||
shell [n] Open a shell inside coold VM n (default: 1)
|
shell [hostname]
|
||||||
|
Open a shell inside a coold VM by Lima hostname (default: coold-dev)
|
||||||
list Show Lima instances
|
list Show Lima instances
|
||||||
clean-vms Delete the coold Lima VMs and all VM-local runtime state (alias for down --cleanup)
|
clean-vms Delete the coold Lima VMs and all VM-local runtime state (alias for down --cleanup)
|
||||||
naked-vm Recreate the naked Lima VM used for bootstrap testing
|
naked-vm Recreate the naked Lima VM used for bootstrap testing
|
||||||
|
|
@ -1143,7 +1160,7 @@ case "$cmd" in
|
||||||
fresh
|
fresh
|
||||||
;;
|
;;
|
||||||
shell)
|
shell)
|
||||||
coold_vm "${1:-1}" shell
|
coold_vm_shell "${1:-}"
|
||||||
;;
|
;;
|
||||||
list)
|
list)
|
||||||
limactl list
|
limactl list
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,50 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\V5\Server as V5Server;
|
||||||
|
use App\Services\Flux\AgentTokenIssuer;
|
||||||
use Firebase\JWT\JWT;
|
use Firebase\JWT\JWT;
|
||||||
use Firebase\JWT\Key;
|
use Firebase\JWT\Key;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Config;
|
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($claims->caps)->toBe(['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([
|
||||||
|
'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('100.64.0.10')
|
||||||
|
->and($claims->caps)->toBe(['host-agent:default'])
|
||||||
|
->and($claims->team_id)->toBe(7)
|
||||||
|
->and($claims->cluster_id)->toBe('cluster-456')
|
||||||
|
->and($claims->server_id)->toBe(123);
|
||||||
|
});
|
||||||
|
|
||||||
it('mints a host jwt signed by the configured flux private key', function () {
|
it('mints a host jwt signed by the configured flux private key', function () {
|
||||||
[$privateKeyPath, $publicKeyPath] = createFluxJwtKeypair();
|
[$privateKeyPath, $publicKeyPath] = createFluxJwtKeypair();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,7 @@
|
||||||
&& str_contains($apps[0]['config'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:3000'))
|
&& str_contains($apps[0]['config'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:3000'))
|
||||||
)
|
)
|
||||||
->andReturn('Caddy ingress applied.');
|
->andReturn('Caddy ingress applied.');
|
||||||
|
expectCaddyIngressFirewallRule($fluxClient);
|
||||||
app()->instance(FluxClient::class, $fluxClient);
|
app()->instance(FluxClient::class, $fluxClient);
|
||||||
|
|
||||||
$this
|
$this
|
||||||
|
|
@ -323,6 +324,20 @@
|
||||||
->toContain('opacity-100');
|
->toContain('opacity-100');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows a loading state on v5 application delete buttons', function () {
|
||||||
|
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'deletingApplicationIds',
|
||||||
|
'const isDeletingApplication = deletingApplicationIds.has(application.id)',
|
||||||
|
'setDeletingApplicationIds',
|
||||||
|
'disabled={isDeletingApplication}',
|
||||||
|
"{isDeletingApplication ? 'Deleting…' : 'Delete'}",
|
||||||
|
] as $expectedSource) {
|
||||||
|
$this->assertTrue(str_contains($dashboardSource, $expectedSource), "Missing source: {$expectedSource}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('uses a larger mobile touch target for v5 application connector dots', function () {
|
it('uses a larger mobile touch target for v5 application connector dots', function () {
|
||||||
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
|
$dashboardSource = file_get_contents(resource_path('js/v5/Pages/Dashboard.tsx'));
|
||||||
|
|
||||||
|
|
@ -805,7 +820,7 @@
|
||||||
->assertDontSee('other-nginx-test', false);
|
->assertDontSee('other-nginx-test', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks v5 application status as stale when its server is unreachable', function () {
|
it('marks v5 application status as unknown when its server is unreachable', function () {
|
||||||
app()->detectEnvironment(fn () => 'local');
|
app()->detectEnvironment(fn () => 'local');
|
||||||
|
|
||||||
$this->withoutVite();
|
$this->withoutVite();
|
||||||
|
|
@ -849,7 +864,7 @@
|
||||||
->get('/v5')
|
->get('/v5')
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('"status":"running"', false)
|
->assertSee('"status":"running"', false)
|
||||||
->assertSee('"effectiveStatus":"unreachable"', false)
|
->assertSee('"effectiveStatus":"unknown"', false)
|
||||||
->assertSee('"effectiveStatusMessage":"coold heartbeat timed out."', false)
|
->assertSee('"effectiveStatusMessage":"coold heartbeat timed out."', false)
|
||||||
->assertSee('"serverStatus":"unreachable"', false)
|
->assertSee('"serverStatus":"unreachable"', false)
|
||||||
->assertSee('"isServerReachable":false', false);
|
->assertSee('"isServerReachable":false', false);
|
||||||
|
|
@ -1767,6 +1782,70 @@
|
||||||
&& $event->caddyIngressServerId === $server->id);
|
&& $event->caddyIngressServerId === $server->id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('broadcasts v5 canvas application updates when a non-ingress server goes unreachable', function () {
|
||||||
|
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 Cluster',
|
||||||
|
]);
|
||||||
|
$server = V5Server::query()->create([
|
||||||
|
'team_id' => $team->id,
|
||||||
|
'cluster_id' => $cluster->id,
|
||||||
|
'created_by_user_id' => $user->id,
|
||||||
|
'name' => 'worker-01',
|
||||||
|
'host' => '203.0.113.11',
|
||||||
|
'ssh_user' => 'root',
|
||||||
|
'ssh_port' => 22,
|
||||||
|
'status' => 'installed',
|
||||||
|
'capabilities' => [],
|
||||||
|
'wireguard_management_ip' => '100.64.0.6',
|
||||||
|
]);
|
||||||
|
$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.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Event::fake([V5CanvasResourceUpdated::class, V5ClusterUpdated::class]);
|
||||||
|
|
||||||
|
$resource = ApplyFluxResourceStatusUpdate::run([
|
||||||
|
'resource_type' => 'server',
|
||||||
|
'host_id' => '100.64.0.6',
|
||||||
|
'status' => 'unreachable',
|
||||||
|
'message' => 'coold heartbeat timed out.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($resource)->toBeInstanceOf(V5Server::class)
|
||||||
|
->and($server->refresh()->status)->toBe('unreachable');
|
||||||
|
|
||||||
|
Event::assertDispatched(V5CanvasResourceUpdated::class, fn (V5CanvasResourceUpdated $event) => $event->teamId === $team->id
|
||||||
|
&& $event->serverId === $server->id);
|
||||||
|
|
||||||
|
$payload = (new V5CanvasResourceUpdated($team->id, serverId: $server->id))->broadcastWith();
|
||||||
|
expect($payload['applications'])
|
||||||
|
->toHaveCount(1)
|
||||||
|
->and($payload['applications'][0])
|
||||||
|
->toMatchArray([
|
||||||
|
'id' => (string) $application->id,
|
||||||
|
'status' => 'running',
|
||||||
|
'effectiveStatus' => 'unknown',
|
||||||
|
'effectiveStatusMessage' => 'coold heartbeat timed out.',
|
||||||
|
'serverStatus' => 'unreachable',
|
||||||
|
'isServerReachable' => false,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('applies flux caddy ingress container status updates without changing server install status', function () {
|
it('applies flux caddy ingress container status updates without changing server install status', function () {
|
||||||
createSharedUserAndTeamTables();
|
createSharedUserAndTeamTables();
|
||||||
|
|
||||||
|
|
@ -1959,6 +2038,108 @@
|
||||||
&& $event->applicationId === $application->id);
|
&& $event->applicationId === $application->id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('broadcasts the full v5 application canvas shape after application state changes', function () {
|
||||||
|
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' => ['ingress'],
|
||||||
|
]);
|
||||||
|
$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' => 'nginx-container-id',
|
||||||
|
'ingress_enabled' => true,
|
||||||
|
'internal_port' => 80,
|
||||||
|
'canvas_x' => 0,
|
||||||
|
'canvas_y' => 0,
|
||||||
|
]);
|
||||||
|
V5ApplicationDomain::query()->create([
|
||||||
|
'application_id' => $application->id,
|
||||||
|
'domain' => 'nginx.example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$application->update([
|
||||||
|
'status' => 'exited',
|
||||||
|
'status_message' => 'Container stopped.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = (new V5CanvasResourceUpdated($team->id, $application->id))->broadcastWith();
|
||||||
|
|
||||||
|
expect($payload['application'])
|
||||||
|
->toMatchArray([
|
||||||
|
'id' => (string) $application->id,
|
||||||
|
'status' => 'exited',
|
||||||
|
'statusMessage' => 'Container stopped.',
|
||||||
|
'effectiveStatus' => 'exited',
|
||||||
|
'effectiveStatusMessage' => 'Container stopped.',
|
||||||
|
'serverName' => 'edge-01',
|
||||||
|
'serverStatus' => 'installed',
|
||||||
|
'isServerReachable' => true,
|
||||||
|
'serverIngressEnabled' => true,
|
||||||
|
'ingressEnabled' => true,
|
||||||
|
'internalPort' => 80,
|
||||||
|
'domains' => ['nginx.example.com'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('broadcasts v5 application status as unknown when its server is unreachable', function () {
|
||||||
|
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' => ['ingress'],
|
||||||
|
]);
|
||||||
|
$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.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = (new V5CanvasResourceUpdated($team->id, $application->id))->broadcastWith();
|
||||||
|
|
||||||
|
expect($payload['application'])
|
||||||
|
->toMatchArray([
|
||||||
|
'status' => 'running',
|
||||||
|
'effectiveStatus' => 'unknown',
|
||||||
|
'effectiveStatusMessage' => 'coold heartbeat timed out.',
|
||||||
|
'serverStatus' => 'unreachable',
|
||||||
|
'isServerReachable' => false,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('broadcasts v5 cluster and canvas updates when ingress server state changes', function () {
|
it('broadcasts v5 cluster and canvas updates when ingress server state changes', function () {
|
||||||
createSharedUserAndTeamTables();
|
createSharedUserAndTeamTables();
|
||||||
|
|
||||||
|
|
@ -3596,6 +3777,7 @@
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
->andReturn('Caddy ingress applied.');
|
->andReturn('Caddy ingress applied.');
|
||||||
|
expectCaddyIngressFirewallRule($fluxClient);
|
||||||
app()->instance(FluxClient::class, $fluxClient);
|
app()->instance(FluxClient::class, $fluxClient);
|
||||||
|
|
||||||
$this
|
$this
|
||||||
|
|
@ -3724,6 +3906,7 @@
|
||||||
&& str_contains($apps[0]['config'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:3000'))
|
&& str_contains($apps[0]['config'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:3000'))
|
||||||
)
|
)
|
||||||
->andReturn('Caddy ingress applied.');
|
->andReturn('Caddy ingress applied.');
|
||||||
|
expectCaddyIngressFirewallRule($fluxClient);
|
||||||
app()->instance(FluxClient::class, $fluxClient);
|
app()->instance(FluxClient::class, $fluxClient);
|
||||||
|
|
||||||
$this
|
$this
|
||||||
|
|
@ -3868,6 +4051,7 @@
|
||||||
&& str_contains($apps[0]['config'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080'))
|
&& str_contains($apps[0]['config'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080'))
|
||||||
)
|
)
|
||||||
->andReturn('Caddy ingress applied.');
|
->andReturn('Caddy ingress applied.');
|
||||||
|
expectCaddyIngressFirewallRule($fluxClient);
|
||||||
app()->instance(FluxClient::class, $fluxClient);
|
app()->instance(FluxClient::class, $fluxClient);
|
||||||
|
|
||||||
$this
|
$this
|
||||||
|
|
@ -5099,6 +5283,22 @@ function fakeSuccessfulNginxFluxDeployment(string $image = 'docker.io/library/ng
|
||||||
app()->instance(FluxClient::class, $mock);
|
app()->instance(FluxClient::class, $mock);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectCaddyIngressFirewallRule(mixed $fluxClient): void
|
||||||
|
{
|
||||||
|
$fluxClient
|
||||||
|
->shouldReceive('applyFirewallRule')
|
||||||
|
->once()
|
||||||
|
->with('100.64.0.10', [
|
||||||
|
'id' => 'v5-caddy-ingress:80',
|
||||||
|
'namespace' => 'default',
|
||||||
|
'src' => '0.0.0.0/0',
|
||||||
|
'dst' => 'coolify-v5-caddy',
|
||||||
|
'proto' => 'tcp',
|
||||||
|
'port' => 80,
|
||||||
|
])
|
||||||
|
->andReturn('Firewall rule applied.');
|
||||||
|
}
|
||||||
|
|
||||||
function createSharedUserAndTeamTables(): void
|
function createSharedUserAndTeamTables(): void
|
||||||
{
|
{
|
||||||
Schema::create('users', function ($table) {
|
Schema::create('users', function ($table) {
|
||||||
|
|
|
||||||
78
tests/Scripts/dev-shell-test.sh
Executable file
78
tests/Scripts/dev-shell-test.sh
Executable file
|
|
@ -0,0 +1,78 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||||
|
|
||||||
|
mkdir -p "$TMP_DIR/bin"
|
||||||
|
cat > "$TMP_DIR/bin/limactl" <<'STUB'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
list)
|
||||||
|
cat <<'LIST'
|
||||||
|
NAME STATUS
|
||||||
|
coold-dev Running
|
||||||
|
coold-dev-2 Running
|
||||||
|
LIST
|
||||||
|
;;
|
||||||
|
shell)
|
||||||
|
printf '%s\n' "$*" > "$LIMACTL_SHELL_ARGS_FILE"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf '%s\n' "$*" > "${LIMACTL_OTHER_ARGS_FILE:-/dev/null}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
STUB
|
||||||
|
chmod +x "$TMP_DIR/bin/limactl"
|
||||||
|
|
||||||
|
export PATH="$TMP_DIR/bin:$PATH"
|
||||||
|
export LIMACTL_SHELL_ARGS_FILE="$TMP_DIR/limactl-shell-args"
|
||||||
|
export LIMACTL_OTHER_ARGS_FILE="$TMP_DIR/limactl-other-args"
|
||||||
|
|
||||||
|
assert_equals() {
|
||||||
|
local expected="$1"
|
||||||
|
local actual="$2"
|
||||||
|
local message="$3"
|
||||||
|
|
||||||
|
if [ "$expected" != "$actual" ]; then
|
||||||
|
echo "FAIL: $message" >&2
|
||||||
|
echo "Expected: $expected" >&2
|
||||||
|
echo "Actual: $actual" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
local needle="$1"
|
||||||
|
local haystack="$2"
|
||||||
|
local message="$3"
|
||||||
|
|
||||||
|
if [[ "$haystack" != *"$needle"* ]]; then
|
||||||
|
echo "FAIL: $message" >&2
|
||||||
|
echo "Expected output to contain: $needle" >&2
|
||||||
|
echo "Actual output: $haystack" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$ROOT"
|
||||||
|
scripts/dev.sh shell coold-dev-2 >/dev/null
|
||||||
|
)
|
||||||
|
assert_equals "shell coold-dev-2 -- sudo env TERM=xterm-256color SYSTEMD_PAGER=cat SYSTEMD_LESS=FRXMK bash -l" "$(cat "$LIMACTL_SHELL_ARGS_FILE")" "shell accepts a Lima hostname"
|
||||||
|
|
||||||
|
set +e
|
||||||
|
numeric_output="$(cd "$ROOT" && scripts/dev.sh shell 1 2>&1 >/dev/null)"
|
||||||
|
numeric_status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "$numeric_status" -eq 0 ]; then
|
||||||
|
echo "FAIL: numeric shell target should be rejected" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
assert_contains "Use the Lima hostname" "$numeric_output" "numeric shell target explains hostname usage"
|
||||||
|
|
||||||
|
echo "dev-shell-test: ok"
|
||||||
|
|
@ -119,6 +119,18 @@
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
->andReturn('Caddy ingress applied.');
|
->andReturn('Caddy ingress applied.');
|
||||||
|
$fluxClient
|
||||||
|
->shouldReceive('applyFirewallRule')
|
||||||
|
->once()
|
||||||
|
->with('100.64.0.10', [
|
||||||
|
'id' => 'v5-caddy-ingress:80',
|
||||||
|
'namespace' => 'default',
|
||||||
|
'src' => '0.0.0.0/0',
|
||||||
|
'dst' => 'coolify-v5-caddy',
|
||||||
|
'proto' => 'tcp',
|
||||||
|
'port' => 80,
|
||||||
|
])
|
||||||
|
->andReturn('Firewall rule applied.');
|
||||||
app()->instance(FluxClient::class, $fluxClient);
|
app()->instance(FluxClient::class, $fluxClient);
|
||||||
|
|
||||||
$result = StartCaddyIngress::run($server);
|
$result = StartCaddyIngress::run($server);
|
||||||
|
|
@ -149,6 +161,11 @@
|
||||||
->once()
|
->once()
|
||||||
->with('100.64.0.10', 'caddy')
|
->with('100.64.0.10', 'caddy')
|
||||||
->andReturn('Caddy ingress stopped.');
|
->andReturn('Caddy ingress stopped.');
|
||||||
|
$fluxClient
|
||||||
|
->shouldReceive('revokeFirewallRule')
|
||||||
|
->once()
|
||||||
|
->with('100.64.0.10', 'v5-caddy-ingress:80')
|
||||||
|
->andReturn('Firewall rule removed.');
|
||||||
app()->instance(FluxClient::class, $fluxClient);
|
app()->instance(FluxClient::class, $fluxClient);
|
||||||
|
|
||||||
$result = StopCaddyIngress::run($server);
|
$result = StopCaddyIngress::run($server);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue