From dbcef807aebe92fca511172033a0ccdf198d06cb Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:54:34 +0000 Subject: [PATCH 001/129] feat(metrics): add reachable /metrics fleet page skeleton --- app/Livewire/Fleet/Metrics.php | 57 +++++++++++++++++++ resources/views/components/navbar.blade.php | 8 +++ .../fleet/metrics-placeholder.blade.php | 3 + .../views/livewire/fleet/metrics.blade.php | 14 +++++ routes/web.php | 2 + .../Livewire/Fleet/MetricsPageTest.php | 33 +++++++++++ 6 files changed, 117 insertions(+) create mode 100644 app/Livewire/Fleet/Metrics.php create mode 100644 resources/views/livewire/fleet/metrics-placeholder.blade.php create mode 100644 resources/views/livewire/fleet/metrics.blade.php create mode 100644 tests/Feature/Livewire/Fleet/MetricsPageTest.php diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php new file mode 100644 index 0000000000..8cbefffd00 --- /dev/null +++ b/app/Livewire/Fleet/Metrics.php @@ -0,0 +1,57 @@ + uuid => name */ + public array $serverOptions = []; + + #[Url(as: 'range')] + public string $range = '24h'; + + #[Url(as: 'server')] + public string $serverUuid = ''; + + public bool $live = false; + + public function mount(): void + { + $allServers = Server::ownedByCurrentTeamCached(); + + $this->servers = $allServers + ->filter(fn (Server $server) => $server->isMetricsEnabled()) + ->values(); + + $this->serverOptions = $this->servers + ->mapWithKeys(fn (Server $server) => [$server->uuid => $server->name]) + ->all(); + + if ($this->serverUuid !== '' && ! array_key_exists($this->serverUuid, $this->serverOptions)) { + $this->serverUuid = ''; + } + } + + public function placeholder(): View + { + return view('livewire.fleet.metrics-placeholder'); + } + + public function render(): View + { + return view('livewire.fleet.metrics'); + } +} diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 251628c496..0722bdd076 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -95,6 +95,14 @@ Analytics +
  • + + + Metrics + +
  • @can('canAccessTerminal')
  • + + diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php new file mode 100644 index 0000000000..69255b779a --- /dev/null +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -0,0 +1,14 @@ +
    +
    +

    Metrics

    +

    Resource usage across your servers.

    +
    + + @if ($servers->isEmpty()) + + + + + + @endif +
    diff --git a/routes/web.php b/routes/web.php index 2a0b66a2ac..0a42e7386d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,7 @@ use App\Livewire\Destination\Index as DestinationIndex; use App\Livewire\Destination\Resources as DestinationResources; use App\Livewire\Destination\Show as DestinationShow; use App\Livewire\Dev\LivewireRequestFailurePreview; +use App\Livewire\Fleet\Metrics as FleetMetrics; use App\Livewire\ForcePasswordReset; use App\Livewire\Notifications\Discord as NotificationDiscord; use App\Livewire\Notifications\Email as NotificationEmail; @@ -161,6 +162,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/', Dashboard::class)->name('dashboard'); Route::get('/analytics', Analytics::class)->name('analytics'); + Route::get('/metrics', FleetMetrics::class)->name('metrics'); Route::get('/admin', AdminIndex::class)->name('admin.index'); Route::get('/onboarding', BoardingIndex::class)->name('onboarding'); diff --git a/tests/Feature/Livewire/Fleet/MetricsPageTest.php b/tests/Feature/Livewire/Fleet/MetricsPageTest.php new file mode 100644 index 0000000000..ad44bf6d72 --- /dev/null +++ b/tests/Feature/Livewire/Fleet/MetricsPageTest.php @@ -0,0 +1,33 @@ + InstanceSettings::query()->create(['id' => 0])); + $user = User::factory()->create(); + $team = Team::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + $this->team = $team; +}); + +it('renders the metrics page with an empty state when no server has metrics enabled', function () { + $component = loadLazy(Livewire::test(Metrics::class)); + + $component + ->assertOk() + ->assertSee('Metrics') + ->assertSee('Enable metrics on a server'); +}); + +it('registers the metrics route', function () { + expect(route('metrics'))->toEndWith('/metrics'); +}); From f950f9058bbefea79017f145ccc6533655728c02 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:54:57 +0000 Subject: [PATCH 002/129] feat(metrics): add pressure threshold helper --- app/Support/Metrics/PressureLevel.php | 39 ++++++++++++++++++++++++ tests/Unit/Metrics/PressureLevelTest.php | 22 +++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 app/Support/Metrics/PressureLevel.php create mode 100644 tests/Unit/Metrics/PressureLevelTest.php diff --git a/app/Support/Metrics/PressureLevel.php b/app/Support/Metrics/PressureLevel.php new file mode 100644 index 0000000000..2904c56504 --- /dev/null +++ b/app/Support/Metrics/PressureLevel.php @@ -0,0 +1,39 @@ += $error => 'error', + $percent >= $warn => 'warning', + default => 'ok', + }; + } + + public static function textClass(string $level): string + { + return match ($level) { + 'error' => 'text-red-500 dark:text-red-400', + 'warning' => 'text-orange-500 dark:text-warning', + default => 'text-black dark:text-fg', + }; + } + + public static function barClass(string $level): string + { + return match ($level) { + 'error' => 'bg-red-500 dark:bg-red-400', + 'warning' => 'bg-orange-500 dark:bg-warning', + default => 'bg-neutral-400 dark:bg-white/30', + }; + } +} diff --git a/tests/Unit/Metrics/PressureLevelTest.php b/tests/Unit/Metrics/PressureLevelTest.php new file mode 100644 index 0000000000..e2c922a216 --- /dev/null +++ b/tests/Unit/Metrics/PressureLevelTest.php @@ -0,0 +1,22 @@ +toBe('ok'); + expect(PressureLevel::for(70, 'cpu'))->toBe('warning'); + expect(PressureLevel::for(89.9, 'memory'))->toBe('warning'); + expect(PressureLevel::for(90, 'memory'))->toBe('error'); +}); + +it('classifies disk pressure at 80/90 cutoffs', function () { + expect(PressureLevel::for(75, 'disk'))->toBe('ok'); + expect(PressureLevel::for(80, 'disk'))->toBe('warning'); + expect(PressureLevel::for(95, 'disk'))->toBe('error'); +}); + +it('treats a null percent as ok and maps levels to classes', function () { + expect(PressureLevel::for(null, 'cpu'))->toBe('ok'); + expect(PressureLevel::textClass('error'))->toContain('text-red-500'); + expect(PressureLevel::barClass('warning'))->toContain('bg-orange-500'); +}); From 30aadf03672bdf945cee47d610b33db6c9e5518a Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:55:29 +0000 Subject: [PATCH 003/129] feat(metrics): aggregate fleet KPIs with weighted math --- app/Services/FleetMetricsAggregator.php | 92 +++++++++++++++++++ .../Services/FleetMetricsAggregatorTest.php | 69 ++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 app/Services/FleetMetricsAggregator.php create mode 100644 tests/Unit/Services/FleetMetricsAggregatorTest.php diff --git a/app/Services/FleetMetricsAggregator.php b/app/Services/FleetMetricsAggregator.php new file mode 100644 index 0000000000..72f6ddb10b --- /dev/null +++ b/app/Services/FleetMetricsAggregator.php @@ -0,0 +1,92 @@ +> $rows + * @return array + */ + public static function fleetKpis(array $rows): array + { + $online = array_values(array_filter($rows, fn ($r) => $r['online'] === true)); + + $cpuValues = self::present($online, 'cpu'); + $cpuAvg = $cpuValues === [] ? 0.0 : round(array_sum($cpuValues) / count($cpuValues), 1); + + $busiest = null; + foreach ($online as $r) { + if ($r['cpu'] !== null && ($busiest === null || $r['cpu'] > $busiest['percent'])) { + $busiest = ['name' => $r['name'], 'percent' => (float) $r['cpu']]; + } + } + + $memUsed = (int) self::sum($online, 'memUsed'); + $memTotal = (int) self::sum($online, 'memTotal'); + $diskUsed = (int) self::sum($online, 'diskUsed'); + $diskTotal = (int) self::sum($online, 'diskTotal'); + $load1 = self::present($online, 'load1'); + + $needsAttention = 0; + foreach ($online as $r) { + $hot = PressureLevel::for($r['cpu'], 'cpu') !== 'ok' + || PressureLevel::for(self::percent($r['memUsed'], $r['memTotal']), 'memory') !== 'ok' + || PressureLevel::for($r['diskPercent'], 'disk') !== 'ok'; + if ($hot) { + $needsAttention++; + } + } + + return [ + 'serversOnline' => count($online), + 'serversTotal' => count($rows), + 'needsAttention' => $needsAttention, + 'cpuAvg' => $cpuAvg, + 'cpuApproximate' => count($cpuValues) > 1, + 'cpuBusiest' => $busiest, + 'memUsed' => $memUsed, + 'memTotal' => $memTotal, + 'memPercent' => self::percent($memUsed, $memTotal), + 'diskUsed' => $diskUsed, + 'diskTotal' => $diskTotal, + 'diskPercent' => self::percent($diskUsed, $diskTotal), + 'netRx' => (float) self::sum($online, 'netRx'), + 'netTx' => (float) self::sum($online, 'netTx'), + 'loadBusiest' => $load1 === [] ? 0.0 : max($load1), + 'loadAvg' => $load1 === [] ? 0.0 : round(array_sum($load1) / count($load1), 2), + 'containers' => (int) self::sum($online, 'containers'), + ]; + } + + /** + * @param array> $rows + * @return array + */ + private static function present(array $rows, string $key): array + { + return array_values(array_map( + fn ($r) => (float) $r[$key], + array_filter($rows, fn ($r) => $r[$key] !== null) + )); + } + + /** + * @param array> $rows + */ + private static function sum(array $rows, string $key): float + { + return array_sum(array_map(fn ($r) => $r[$key] ?? 0, $rows)); + } + + private static function percent(int|float|null $used, int|float|null $total): float + { + if (! $total) { + return 0.0; + } + + return round(($used / $total) * 100, 1); + } +} diff --git a/tests/Unit/Services/FleetMetricsAggregatorTest.php b/tests/Unit/Services/FleetMetricsAggregatorTest.php new file mode 100644 index 0000000000..2de55616f7 --- /dev/null +++ b/tests/Unit/Services/FleetMetricsAggregatorTest.php @@ -0,0 +1,69 @@ + 'u', 'name' => 'srv', 'online' => true, + 'cpu' => 10.0, 'memUsed' => 1_000, 'memTotal' => 10_000, + 'diskUsed' => 1_000, 'diskTotal' => 10_000, 'diskPercent' => 10.0, + 'load1' => 0.5, 'netRx' => 100.0, 'netTx' => 50.0, 'containers' => 2, + ], $overrides); +} + +it('weights memory and disk by totals, not mean of percents', function () { + $rows = [ + row(['memUsed' => 100, 'memTotal' => 100, 'diskUsed' => 100, 'diskTotal' => 100, 'diskPercent' => 100.0]), + row(['memUsed' => 10, 'memTotal' => 1_000, 'diskUsed' => 10, 'diskTotal' => 1_000, 'diskPercent' => 1.0]), + ]; + + $kpis = FleetMetricsAggregator::fleetKpis($rows); + + // Σused/Σtotal = 110/1100 = 10%, not the (100+1)/2 = 50.5% a naive mean gives. + expect(round($kpis['memPercent'], 1))->toBe(10.0); + expect(round($kpis['diskPercent'], 1))->toBe(10.0); +}); + +it('means cpu, flags approximate for multiple servers, and finds the busiest', function () { + $kpis = FleetMetricsAggregator::fleetKpis([ + row(['name' => 'a', 'cpu' => 20.0]), + row(['name' => 'b', 'cpu' => 80.0]), + ]); + + expect($kpis['cpuAvg'])->toBe(50.0); + expect($kpis['cpuApproximate'])->toBeTrue(); + expect($kpis['cpuBusiest'])->toBe(['name' => 'b', 'percent' => 80.0]); +}); + +it('sums network rates and reports busiest and average load', function () { + $kpis = FleetMetricsAggregator::fleetKpis([ + row(['netRx' => 100.0, 'netTx' => 10.0, 'load1' => 0.5]), + row(['netRx' => 200.0, 'netTx' => 20.0, 'load1' => 2.5]), + ]); + + expect($kpis['netRx'])->toBe(300.0); + expect($kpis['netTx'])->toBe(30.0); + expect($kpis['loadBusiest'])->toBe(2.5); + expect($kpis['loadAvg'])->toBe(1.5); +}); + +it('ignores offline rows and counts servers needing attention', function () { + $kpis = FleetMetricsAggregator::fleetKpis([ + row(['cpu' => 95.0]), // over cpu error cutoff + row(['online' => false, 'cpu' => 99.0]), + ]); + + expect($kpis['serversOnline'])->toBe(1); + expect($kpis['serversTotal'])->toBe(2); + expect($kpis['needsAttention'])->toBe(1); + expect($kpis['cpuAvg'])->toBe(95.0); // offline row excluded from the mean +}); + +it('returns zeroed kpis for no rows', function () { + $kpis = FleetMetricsAggregator::fleetKpis([]); + + expect($kpis['serversOnline'])->toBe(0); + expect($kpis['cpuAvg'])->toBe(0.0); + expect($kpis['cpuBusiest'])->toBeNull(); +}); From bc3ec590d01d94b106928ba4977173913c55e512 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:55:50 +0000 Subject: [PATCH 004/129] feat(metrics): add bucket series merge and container ranking --- app/Services/FleetMetricsAggregator.php | 44 +++++++++++++++++++ .../FleetMetricsAggregatorRankingTest.php | 33 ++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tests/Unit/Services/FleetMetricsAggregatorRankingTest.php diff --git a/app/Services/FleetMetricsAggregator.php b/app/Services/FleetMetricsAggregator.php index 72f6ddb10b..29e0459d3b 100644 --- a/app/Services/FleetMetricsAggregator.php +++ b/app/Services/FleetMetricsAggregator.php @@ -61,6 +61,50 @@ class FleetMetricsAggregator ]; } + /** + * @param array> $seriesPerServer + * @return array + */ + public static function sumSeriesByBucket(array $seriesPerServer, string $mode): array + { + $totals = []; + $counts = []; + + foreach ($seriesPerServer as $series) { + foreach ($series as [$ts, $value]) { + $totals[$ts] = ($totals[$ts] ?? 0.0) + (float) $value; + $counts[$ts] = ($counts[$ts] ?? 0) + 1; + } + } + + ksort($totals); + + $out = []; + foreach ($totals as $ts => $sum) { + $out[] = [(int) $ts, $mode === 'avg' ? round($sum / $counts[$ts], 2) : round($sum, 2)]; + } + + return $out; + } + + /** + * @param array> $containers + * @return array> + */ + public static function rankContainers(array $containers, string $metric, int $limit = 20): array + { + $key = match ($metric) { + 'memory' => 'memUsed', + 'disk' => 'diskBytes', + 'network' => 'net', + default => 'cpu', + }; + + usort($containers, fn ($a, $b) => ($b[$key] ?? 0) <=> ($a[$key] ?? 0)); + + return array_slice($containers, 0, $limit); + } + /** * @param array> $rows * @return array diff --git a/tests/Unit/Services/FleetMetricsAggregatorRankingTest.php b/tests/Unit/Services/FleetMetricsAggregatorRankingTest.php new file mode 100644 index 0000000000..6b6fe2ee1e --- /dev/null +++ b/tests/Unit/Services/FleetMetricsAggregatorRankingTest.php @@ -0,0 +1,33 @@ +toBe([[1000, 3.0], [2000, 5.0], [3000, 9.0]]); +}); + +it('averages series across servers per bucket', function () { + $merged = FleetMetricsAggregator::sumSeriesByBucket([ + [[1000, 20.0]], + [[1000, 40.0]], + ], 'avg'); + + expect($merged)->toBe([[1000, 30.0]]); +}); + +it('ranks containers by the selected metric and caps the list', function () { + $containers = [ + ['id' => 'a', 'cpu' => 10.0, 'memUsed' => 500, 'diskBytes' => 5, 'net' => 1.0], + ['id' => 'b', 'cpu' => 90.0, 'memUsed' => 100, 'diskBytes' => 9, 'net' => 2.0], + ['id' => 'c', 'cpu' => 50.0, 'memUsed' => 900, 'diskBytes' => 1, 'net' => 3.0], + ]; + + expect(array_column(FleetMetricsAggregator::rankContainers($containers, 'cpu', 2), 'id'))->toBe(['b', 'c']); + expect(array_column(FleetMetricsAggregator::rankContainers($containers, 'memory'), 'id'))->toBe(['c', 'a', 'b']); + expect(array_column(FleetMetricsAggregator::rankContainers($containers, 'network'), 'id'))->toBe(['c', 'b', 'a']); +}); From ba8ed4e8fb2d80415ef434ca6e7d05e67dac0da6 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:56:44 +0000 Subject: [PATCH 005/129] feat(metrics): add SentinelMetricsClient with graceful degradation --- app/Services/SentinelMetricsClient.php | 199 ++++++++++++++++++ .../Services/SentinelMetricsClientTest.php | 106 ++++++++++ 2 files changed, 305 insertions(+) create mode 100644 app/Services/SentinelMetricsClient.php create mode 100644 tests/Unit/Services/SentinelMetricsClientTest.php diff --git a/app/Services/SentinelMetricsClient.php b/app/Services/SentinelMetricsClient.php new file mode 100644 index 0000000000..5f27f1e564 --- /dev/null +++ b/app/Services/SentinelMetricsClient.php @@ -0,0 +1,199 @@ + now()->subDays(7), + '30d' => now()->subDays(30), + default => now()->subDay(), + }; + + return $from->toIso8601ZuluString(); + } + + /** + * @return array|null + */ + public function summary(): ?array + { + try { + $json = json_decode($this->raw($this->url('/summary')), true); + } catch (\Throwable) { + return null; + } + + if (! is_array($json)) { + return null; + } + + $disk = $this->rootMount($json['disk'] ?? []); + + return [ + 'online' => true, + 'cpu' => isset($json['cpu']['percent']) ? (float) $json['cpu']['percent'] : null, + 'memUsed' => isset($json['memory']['used']) ? (int) $json['memory']['used'] : null, + 'memTotal' => isset($json['memory']['total']) ? (int) $json['memory']['total'] : null, + 'diskUsed' => $disk['used'], + 'diskTotal' => $disk['total'], + 'diskPercent' => $disk['percent'], + 'load1' => isset($json['load']['load1']) ? (float) $json['load']['load1'] : null, + 'netRx' => isset($json['network']['rxBytesPerSec']) ? (float) $json['network']['rxBytesPerSec'] : null, + 'netTx' => isset($json['network']['txBytesPerSec']) ? (float) $json['network']['txBytesPerSec'] : null, + ]; + } + + /** + * @return array> + */ + public function containersCurrent(): array + { + try { + $rows = json_decode($this->raw($this->url('/containers/current')), true); + } catch (\Throwable) { + return []; + } + + if (! is_array($rows)) { + return []; + } + + return array_values(array_map(function ($c) { + $disk = ($c['disk']['writableLayer'] ?? 0) + ($c['disk']['volumesTotal'] ?? 0); + $net = ($c['network']['rxBytesPerSec'] ?? 0) + ($c['network']['txBytesPerSec'] ?? 0); + + return [ + 'id' => (string) ($c['id'] ?? ''), + 'cpu' => isset($c['cpu']['percent']) ? (float) $c['cpu']['percent'] : null, + 'memUsed' => isset($c['memory']['used']) ? (int) $c['memory']['used'] : null, + 'memPercent' => isset($c['memory']['usedPercent']) ? (float) $c['memory']['usedPercent'] : null, + 'diskBytes' => isset($c['disk']) ? (int) $disk : null, + 'net' => isset($c['network']) ? (float) $net : null, + ]; + }, $rows)); + } + + /** + * @return array + */ + public function history(string $metric, string $from): array + { + [$path, $field] = match ($metric) { + 'memory' => ['/memory/history', 'used'], + 'disk' => ['/disk/history', 'usedPercent'], + 'load' => ['/load/history', 'load1'], + default => ['/cpu/history', 'percent'], + }; + + return $this->seriesRows($this->url($path, ['from' => $from]), $field); + } + + /** + * @return array{rx: array, tx: array} + */ + public function networkHistory(string $from): array + { + $url = $this->url('/network/history', ['from' => $from]); + + return [ + 'rx' => $this->seriesRows($url, 'rxBytesPerSec'), + 'tx' => $this->seriesRows($url, 'txBytesPerSec'), + ]; + } + + /** + * @return array + */ + private function seriesRows(string $url, string $field): array + { + try { + $rows = json_decode($this->raw($url), true); + } catch (\Throwable) { + return []; + } + + if (! is_array($rows)) { + return []; + } + + return array_values(array_map( + fn ($r) => [(int) ($r['time'] ?? 0), (float) ($r[$field] ?? 0)], + $rows + )); + } + + /** + * @param array> $mounts + * @return array{used: ?int, total: ?int, percent: ?float} + */ + private function rootMount(array $mounts): array + { + if ($mounts === []) { + return ['used' => null, 'total' => null, 'percent' => null]; + } + + $root = collect($mounts)->firstWhere('mount', '/') ?? $mounts[0]; + + return [ + 'used' => (int) ($root['used'] ?? 0), + 'total' => (int) ($root['total'] ?? 0), + 'percent' => (float) ($root['usedPercent'] ?? 0), + ]; + } + + /** + * @param array $query + */ + private function url(string $path, array $query = []): string + { + $qs = empty($query) ? '' : '?'.str_replace('%3A', ':', http_build_query($query)); + + return $this->base.$path.$qs; + } + + private function cacheKey(string $url): string + { + return 'metrics:'.$this->server->uuid.':'.md5($url); + } + + protected function raw(string $url): string + { + return Cache::remember($this->cacheKey($url), 60, fn () => $this->guard($this->remoteFetch($url))); + } + + protected function remoteFetch(string $url): string + { + $token = $this->server->settings->ensureValidSentinelToken(); + + return instant_remote_process( + ["docker exec coolify-sentinel sh -c 'curl -s -H \"Authorization: Bearer {$token}\" \"{$url}\"'"], + $this->server, + false + ); + } + + private function guard(string $response): string + { + $payload = json_decode($response, true); + + if ($payload === null) { + throw new \RuntimeException('Metrics returned an invalid response.'); + } + + if (is_array($payload) && array_key_exists('error', $payload)) { + throw new \RuntimeException('Metrics request failed.'); + } + + return $response; + } +} diff --git a/tests/Unit/Services/SentinelMetricsClientTest.php b/tests/Unit/Services/SentinelMetricsClientTest.php new file mode 100644 index 0000000000..1b1088169e --- /dev/null +++ b/tests/Unit/Services/SentinelMetricsClientTest.php @@ -0,0 +1,106 @@ + substring => body */ + public array $bodies = []; + + /** @var array */ + public array $failing = []; + + protected function raw(string $url): string + { + foreach ($this->failing as $needle) { + if (str_contains($url, $needle)) { + throw new RuntimeException('boom'); + } + } + foreach ($this->bodies as $needle => $body) { + if (str_contains($url, $needle)) { + return $body; + } + } + + return '[]'; + } +} + +function fakeMetricsClient(): FakeMetricsClient +{ + $server = Server::factory()->make(['uuid' => 'srv-uuid']); + + return new FakeMetricsClient($server); +} + +it('normalizes the host summary, choosing the root disk mount', function () { + $client = fakeMetricsClient(); + $client->bodies = [ + '/summary' => json_encode([ + 'cpu' => ['time' => 't', 'percent' => 42.5], + 'memory' => ['time' => 't', 'total' => 1000, 'used' => 250, 'usedPercent' => 25.0], + 'disk' => [ + ['time' => 't', 'mount' => '/boot', 'total' => 10, 'used' => 9, 'usedPercent' => 90.0], + ['time' => 't', 'mount' => '/', 'total' => 100, 'used' => 40, 'usedPercent' => 40.0], + ], + 'network' => ['time' => 't', 'rxBytesPerSec' => 100.0, 'txBytesPerSec' => 20.0], + 'load' => ['time' => 't', 'load1' => 1.5, 'load5' => 1.0, 'load15' => 0.5], + ]), + ]; + + $summary = $client->summary(); + + expect($summary['cpu'])->toBe(42.5); + expect($summary['memUsed'])->toBe(250); + expect($summary['diskPercent'])->toBe(40.0); // root mount, not /boot + expect($summary['netRx'])->toBe(100.0); + expect($summary['load1'])->toBe(1.5); +}); + +it('returns null summary when the endpoint fails', function () { + $client = fakeMetricsClient(); + $client->failing = ['/summary']; + + expect($client->summary())->toBeNull(); +}); + +it('normalizes containers, parsing the stringified cpu percent', function () { + $client = fakeMetricsClient(); + $client->bodies = [ + '/containers/current' => json_encode([ + ['id' => 'app-a', 'cpu' => ['time' => 't', 'percent' => '12.5'], + 'memory' => ['used' => 500, 'usedPercent' => 5.0], + 'disk' => ['writableLayer' => 3, 'volumesTotal' => 7], + 'network' => ['rxBytesPerSec' => 4.0, 'txBytesPerSec' => 1.0]], + ]), + ]; + + $rows = $client->containersCurrent(); + + expect($rows[0]['id'])->toBe('app-a'); + expect($rows[0]['cpu'])->toBe(12.5); + expect($rows[0]['memUsed'])->toBe(500); + expect($rows[0]['diskBytes'])->toBe(10); // writableLayer + volumesTotal + expect($rows[0]['net'])->toBe(5.0); // rx + tx +}); + +it('returns an empty container list when the endpoint 404s', function () { + $client = fakeMetricsClient(); + $client->failing = ['/containers/current']; + + expect($client->containersCurrent())->toBe([]); +}); + +it('maps cpu history to [time, value] pairs', function () { + $client = fakeMetricsClient(); + $client->bodies = [ + '/cpu/history' => json_encode([ + ['time' => 1000, 'percent' => '10'], + ['time' => 2000, 'percent' => '20'], + ]), + ]; + + expect($client->history('cpu', 'from'))->toBe([[1000, 10.0], [2000, 20.0]]); +}); From fb021c3d9d80d5ace1cd555dabfc686861e12242 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 07:58:55 +0000 Subject: [PATCH 006/129] feat(metrics): populate fleet KPIs, server rows, and container ranking --- app/Livewire/Fleet/Metrics.php | 149 ++++++++++++++++++ .../Livewire/Fleet/MetricsDataTest.php | 128 +++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 tests/Feature/Livewire/Fleet/MetricsDataTest.php diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index 8cbefffd00..63dfd5605a 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -2,7 +2,10 @@ namespace App\Livewire\Fleet; +use App\Models\Application; use App\Models\Server; +use App\Services\FleetMetricsAggregator; +use App\Services\SentinelMetricsClient; use Illuminate\Contracts\View\View; use Illuminate\Support\Collection; use Livewire\Attributes\Lazy; @@ -26,8 +29,23 @@ class Metrics extends Component #[Url(as: 'server')] public string $serverUuid = ''; + #[Url(as: 'cmetric')] + public string $containerMetric = 'cpu'; + public bool $live = false; + /** @var array */ + public array $kpis = []; + + /** @var array> */ + public array $serverRows = []; + + /** @var array> */ + public array $topContainers = []; + + /** @var array */ + protected array $containerMetaCache = []; + public function mount(): void { $allServers = Server::ownedByCurrentTeamCached(); @@ -43,6 +61,137 @@ class Metrics extends Component if ($this->serverUuid !== '' && ! array_key_exists($this->serverUuid, $this->serverOptions)) { $this->serverUuid = ''; } + + if ($this->servers->isNotEmpty()) { + $this->loadData(); + } + } + + public function updatedServerUuid(): void + { + $this->loadData(); + } + + public function updatedContainerMetric(): void + { + $this->loadData(); + } + + public function setRange(string $range): void + { + $this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; + $this->loadData(); + } + + /** + * @return Collection + */ + protected function targetServers(): Collection + { + if ($this->serverUuid !== '') { + return $this->servers->filter(fn (Server $s) => $s->uuid === $this->serverUuid)->values(); + } + + return $this->servers; + } + + public function loadData(): void + { + if ($this->servers->isEmpty()) { + return; + } + + $rows = []; + $containers = []; + + foreach ($this->targetServers() as $server) { + $client = $this->metricsClient($server); + + try { + $summary = $client->summary(); + } catch (\Throwable) { + $summary = null; + } + + if ($summary === null) { + $rows[] = $this->offlineRow($server); + + continue; + } + + $current = $client->containersCurrent(); + $rows[] = array_merge( + ['uuid' => $server->uuid, 'name' => $server->name, 'online' => true, 'containers' => count($current)], + $summary + ); + + foreach ($current as $c) { + $meta = $this->resolveContainer($server, $c['id']); + $containers[] = array_merge($c, ['server' => $server->name, 'name' => $meta['name'], 'link' => $meta['link']]); + } + } + + $this->serverRows = $rows; + $this->kpis = FleetMetricsAggregator::fleetKpis($rows); + $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); + + $this->dispatch("refreshChartData-{$this->chartId}", $this->chartPayload()); + } + + /** + * @return array + */ + protected function offlineRow(Server $server): array + { + return [ + 'uuid' => $server->uuid, 'name' => $server->name, 'online' => false, + 'cpu' => null, 'memUsed' => null, 'memTotal' => null, + 'diskUsed' => null, 'diskTotal' => null, 'diskPercent' => null, + 'load1' => null, 'netRx' => null, 'netTx' => null, 'containers' => null, + ]; + } + + protected function metricsClient(Server $server): SentinelMetricsClient + { + return app(SentinelMetricsClient::class, ['server' => $server]); + } + + /** + * Resolve a Sentinel container id (the resource uuid Sentinel keys by) to a + * team-owned resource name + metrics link, memoized. Shows the raw id when no + * team-owned resource matches, so a Sentinel id never discloses another team's name. + * + * @return array{name: string, link: ?string} + */ + protected function resolveContainer(Server $server, string $id): array + { + if (isset($this->containerMetaCache[$id])) { + return $this->containerMetaCache[$id]; + } + + $app = Application::ownedByCurrentTeam()->with('environment.project')->whereUuid($id)->first(); + + $link = null; + if ($app && data_get($app, 'environment.project.uuid')) { + $link = route('project.application.metrics', [ + 'project_uuid' => $app->environment->project->uuid, + 'environment_uuid' => $app->environment->uuid, + 'application_uuid' => $app->uuid, + ]); + } + + return $this->containerMetaCache[$id] = [ + 'name' => $app?->name ?? $id, + 'link' => $link, + ]; + } + + /** + * @return array + */ + protected function chartPayload(): array + { + return ['range' => $this->range]; } public function placeholder(): View diff --git a/tests/Feature/Livewire/Fleet/MetricsDataTest.php b/tests/Feature/Livewire/Fleet/MetricsDataTest.php new file mode 100644 index 0000000000..1ef9cd5b32 --- /dev/null +++ b/tests/Feature/Livewire/Fleet/MetricsDataTest.php @@ -0,0 +1,128 @@ + serverUuid => summary */ + public static array $summaries = []; + + /** @var array serverUuid => containers */ + public static array $containers = []; + + public function summary(): ?array + { + return self::$summaries[$this->server->uuid] ?? null; + } + + public function containersCurrent(): array + { + return self::$containers[$this->server->uuid] ?? []; + } + + public function history(string $metric, string $from): array + { + return []; + } + + public function networkHistory(string $from): array + { + return ['rx' => [], 'tx' => []]; + } +} + +class TestableMetrics extends Metrics +{ + protected function metricsClient(Server $server): SentinelMetricsClient + { + return new StubMetricsClient($server); + } +} + +beforeEach(function () { + StubMetricsClient::$summaries = []; + StubMetricsClient::$containers = []; + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + $user = User::factory()->create(); + $team = Team::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $this->team = $team; +}); + +function metricsServer($team, $privateKey): Server +{ + $server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]); + $server->settings->update(['is_metrics_enabled' => true]); + + return $server; +} + +function onlineSummary(array $overrides = []): array +{ + return array_merge([ + 'online' => true, 'cpu' => 30.0, 'memUsed' => 100, 'memTotal' => 200, + 'diskUsed' => 10, 'diskTotal' => 100, 'diskPercent' => 10.0, + 'load1' => 0.4, 'netRx' => 5.0, 'netTx' => 1.0, + ], $overrides); +} + +it('keeps an unreachable server in the table but excludes it from KPIs', function () { + $ok = metricsServer($this->team, $this->privateKey); + $dead = metricsServer($this->team, $this->privateKey); + + StubMetricsClient::$summaries = [ + $ok->uuid => onlineSummary(['cpu' => 30.0]), + $dead->uuid => null, + ]; + + $component = Livewire::test(TestableMetrics::class); + + expect($component->get('serverRows'))->toHaveCount(2); + + $deadRow = collect($component->get('serverRows'))->firstWhere('uuid', $dead->uuid); + expect($deadRow['online'])->toBeFalse(); + expect($deadRow['cpu'])->toBeNull(); + + expect($component->get('kpis')['serversOnline'])->toBe(1); + expect($component->get('kpis')['cpuAvg'])->toBe(30.0); +}); + +it('ranks containers across servers by the selected metric', function () { + $s = metricsServer($this->team, $this->privateKey); + + StubMetricsClient::$summaries = [$s->uuid => onlineSummary(['diskPercent' => 50.0])]; + StubMetricsClient::$containers = [$s->uuid => [ + ['id' => 'low', 'cpu' => 5.0, 'memUsed' => 10, 'memPercent' => 1.0, 'diskBytes' => 1, 'net' => 0.0], + ['id' => 'high', 'cpu' => 88.0, 'memUsed' => 20, 'memPercent' => 2.0, 'diskBytes' => 2, 'net' => 0.0], + ]]; + + $component = Livewire::test(TestableMetrics::class); + + expect(array_column($component->get('topContainers'), 'id')[0])->toBe('high'); +}); + +it('cannot see another team’s servers', function () { + $otherTeam = Team::factory()->create(); + // Reuse the existing key id: the factory's private key is hardcoded, so a second + // PrivateKey::create collides on fingerprint. Only the server's team_id matters here. + $server = Server::factory()->create(['team_id' => $otherTeam->id, 'private_key_id' => $this->privateKey->id]); + $server->settings->update(['is_metrics_enabled' => true]); + + $component = Livewire::test(TestableMetrics::class); + + expect($component->get('serverRows'))->toHaveCount(0); +}); From 5273a4c035bb1ce38d214c3092bac933bdb59e2a Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:01:11 +0000 Subject: [PATCH 007/129] feat(metrics): build and dispatch fleet trend chart payloads --- .../Concerns/BuildsMetricsChartPayload.php | 25 +++++++ app/Livewire/Fleet/Metrics.php | 23 +++--- .../Fleet/MetricsChartPayloadTest.php | 73 +++++++++++++++++++ 3 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 app/Livewire/Concerns/BuildsMetricsChartPayload.php create mode 100644 tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php diff --git a/app/Livewire/Concerns/BuildsMetricsChartPayload.php b/app/Livewire/Concerns/BuildsMetricsChartPayload.php new file mode 100644 index 0000000000..c660ed49d3 --- /dev/null +++ b/app/Livewire/Concerns/BuildsMetricsChartPayload.php @@ -0,0 +1,25 @@ + + */ + protected function buildChartPayload(array $seriesByServer): array + { + return [ + 'range' => $this->range, + 'cpu' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['cpu'], 'avg'), + 'memory' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['memory'], 'sum'), + 'disk' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['disk'], 'avg'), + 'load' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['load'], 'avg'), + 'networkRx' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkRx'], 'sum'), + 'networkTx' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkTx'], 'sum'), + ]; + } +} diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index 63dfd5605a..1de503492f 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -2,6 +2,7 @@ namespace App\Livewire\Fleet; +use App\Livewire\Concerns\BuildsMetricsChartPayload; use App\Models\Application; use App\Models\Server; use App\Services\FleetMetricsAggregator; @@ -15,6 +16,8 @@ use Livewire\Component; #[Lazy] class Metrics extends Component { + use BuildsMetricsChartPayload; + public string $chartId = 'fleet-metrics'; /** Metrics-enabled servers owned by the current team. */ @@ -103,6 +106,8 @@ class Metrics extends Component $rows = []; $containers = []; + $series = ['cpu' => [], 'memory' => [], 'disk' => [], 'load' => [], 'networkRx' => [], 'networkTx' => []]; + $from = SentinelMetricsClient::rangeFrom($this->range); foreach ($this->targetServers() as $server) { $client = $this->metricsClient($server); @@ -129,13 +134,21 @@ class Metrics extends Component $meta = $this->resolveContainer($server, $c['id']); $containers[] = array_merge($c, ['server' => $server->name, 'name' => $meta['name'], 'link' => $meta['link']]); } + + $series['cpu'][] = $client->history('cpu', $from); + $series['memory'][] = $client->history('memory', $from); + $series['disk'][] = $client->history('disk', $from); + $series['load'][] = $client->history('load', $from); + $net = $client->networkHistory($from); + $series['networkRx'][] = $net['rx']; + $series['networkTx'][] = $net['tx']; } $this->serverRows = $rows; $this->kpis = FleetMetricsAggregator::fleetKpis($rows); $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); - $this->dispatch("refreshChartData-{$this->chartId}", $this->chartPayload()); + $this->dispatch("refreshChartData-{$this->chartId}", $this->buildChartPayload($series)); } /** @@ -186,14 +199,6 @@ class Metrics extends Component ]; } - /** - * @return array - */ - protected function chartPayload(): array - { - return ['range' => $this->range]; - } - public function placeholder(): View { return view('livewire.fleet.metrics-placeholder'); diff --git a/tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php b/tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php new file mode 100644 index 0000000000..0dc92d383d --- /dev/null +++ b/tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php @@ -0,0 +1,73 @@ + true, 'cpu' => 10.0, 'memUsed' => 1, 'memTotal' => 2, + 'diskUsed' => 1, 'diskTotal' => 2, 'diskPercent' => 50.0, 'load1' => 0.1, 'netRx' => 0.0, 'netTx' => 0.0]; + } + + public function containersCurrent(): array + { + return []; + } + + public function history(string $metric, string $from): array + { + return [[1000, 10.0], [2000, 20.0]]; + } + + public function networkHistory(string $from): array + { + return ['rx' => [[1000, 5.0]], 'tx' => [[1000, 1.0]]]; + } +} + +class ChartTestableMetrics extends Metrics +{ + protected function metricsClient(Server $server): SentinelMetricsClient + { + return new ChartStubClient($server); + } +} + +beforeEach(function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + $user = User::factory()->create(); + $team = Team::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $this->team = $team; +}); + +it('dispatches a chart payload with every trend series', function () { + $s = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]); + $s->settings->update(['is_metrics_enabled' => true]); + + Livewire::test(ChartTestableMetrics::class) + ->call('loadData') + ->assertDispatched('refreshChartData-fleet-metrics', function ($event, $params) { + $payload = $params[0]; + + // Livewire JSON-serializes event params, so whole floats arrive as ints; + // compare loosely (10 == 10.0). + return $payload['cpu'] == [[1000, 10], [2000, 20]] + && $payload['networkRx'] == [[1000, 5]] + && array_key_exists('load', $payload); + }); +}); From 6b9086795f07107a0d846c9a2a7a3e0df2a63ed1 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:05:41 +0000 Subject: [PATCH 008/129] feat(metrics): render fleet KPIs, tables, and trend charts --- app/Livewire/Fleet/Metrics.php | 18 ++ .../livewire/fleet/_usage-cell.blade.php | 12 + .../views/livewire/fleet/metrics.blade.php | 210 +++++++++++++++++- 3 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 resources/views/livewire/fleet/_usage-cell.blade.php diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index 1de503492f..c47d282033 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -46,6 +46,8 @@ class Metrics extends Component /** @var array> */ public array $topContainers = []; + public ?string $lastUpdatedAt = null; + /** @var array */ protected array $containerMetaCache = []; @@ -86,6 +88,21 @@ class Metrics extends Component $this->loadData(); } + public function isLivePollable(): bool + { + return $this->live && $this->range === '24h'; + } + + /** + * Re-pull within the 60s cache TTL. The TTL bounds staleness, so a refresh is a + * cheap cache-hit reload rather than a global cache flush (matches the Analytics + * live toggle, which also relies on the TTL). + */ + public function refresh(): void + { + $this->loadData(); + } + /** * @return Collection */ @@ -147,6 +164,7 @@ class Metrics extends Component $this->serverRows = $rows; $this->kpis = FleetMetricsAggregator::fleetKpis($rows); $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); + $this->lastUpdatedAt = now()->toIso8601String(); $this->dispatch("refreshChartData-{$this->chartId}", $this->buildChartPayload($series)); } diff --git a/resources/views/livewire/fleet/_usage-cell.blade.php b/resources/views/livewire/fleet/_usage-cell.blade.php new file mode 100644 index 0000000000..55c233996a --- /dev/null +++ b/resources/views/livewire/fleet/_usage-cell.blade.php @@ -0,0 +1,12 @@ +@php + $level = \App\Support\Metrics\PressureLevel::for($percent, $kind); + $width = $percent === null ? 0 : min(100, round($percent, 1)); +@endphp +
    + + {{ $percent === null ? '-' : round($percent).'%' }} + + +
    diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index 69255b779a..f3a5857126 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -1,7 +1,33 @@
    -
    -

    Metrics

    -

    Resource usage across your servers.

    + {{-- Header + controls --}} +
    +
    +

    Metrics

    +

    Resource usage across your servers.

    +
    + @if ($servers->isNotEmpty()) +
    + @if ($lastUpdatedAt) + + @endif + +
    + @foreach (['24h', '7d', '30d'] as $r) + + @endforeach +
    + +
    + @endif
    @if ($servers->isEmpty()) @@ -10,5 +36,183 @@ + @else + {{-- KPI tiles --}} +
    +
    + Servers online + {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} + @if (($kpis['needsAttention'] ?? 0) > 0) + {{ $kpis['needsAttention'] }} needs attention + @endif +
    +
    + Fleet CPU @if ($kpis['cpuApproximate'] ?? false)~@endif + {{ round($kpis['cpuAvg'] ?? 0) }}% + @if ($kpis['cpuBusiest'] ?? null){{ $kpis['cpuBusiest']['name'] }}: {{ round($kpis['cpuBusiest']['percent']) }}%@endif +
    +
    + Fleet memory + {{ round($kpis['memPercent'] ?? 0) }}% + {{ formatBytes($kpis['memUsed'] ?? 0) }} / {{ formatBytes($kpis['memTotal'] ?? 0) }} +
    +
    + Fleet disk + {{ round($kpis['diskPercent'] ?? 0) }}% + {{ formatBytes($kpis['diskUsed'] ?? 0) }} / {{ formatBytes($kpis['diskTotal'] ?? 0) }} +
    +
    + Fleet network + {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s + rx {{ formatBytes($kpis['netRx'] ?? 0) }}/s tx {{ formatBytes($kpis['netTx'] ?? 0) }}/s +
    +
    + Containers + {{ compactNumber($kpis['containers'] ?? 0) }} +
    +
    + + {{-- Trend charts --}} +
    + @foreach (['cpu' => 'CPU %', 'memory' => 'Memory used', 'network' => 'Network', 'load' => 'Load average', 'disk' => 'Disk %'] as $key => $label) + +
    +
    +
    +
    + @endforeach +
    + + {{-- Servers table --}} + + + + + + + + + @foreach (collect($serverRows)->sortByDesc('cpu') as $row) + + + + + + + + + + @endforeach + +
    ServerCPUMemoryDiskLoadNetworkContainers
    + {{ $row['name'] }} + @unless ($row['online']) + + @endunless + @include('livewire.fleet._usage-cell', ['percent' => $row['cpu'], 'kind' => 'cpu'])@include('livewire.fleet._usage-cell', ['percent' => ($row['memTotal'] ?? 0) ? round($row['memUsed'] / $row['memTotal'] * 100, 1) : null, 'kind' => 'memory'])@include('livewire.fleet._usage-cell', ['percent' => $row['diskPercent'], 'kind' => 'disk']){{ $row['load1'] === null ? '-' : number_format($row['load1'], 2) }}{{ $row['netRx'] === null ? '-' : formatBytes($row['netRx'] + $row['netTx']).'/s' }}{{ $row['containers'] ?? '-' }}
    +
    + + {{-- Hottest containers --}} + + +
    + @foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'] as $m => $mlabel) + + @endforeach +
    +
    + @if (empty($topContainers)) + + @else + + + + @foreach ($topContainers as $c) + + + + + + @endforeach + +
    ContainerServer{{ ucfirst($containerMetric) }}
    @if ($c['link']){{ $c['name'] }}@else{{ $c['name'] }}@endif{{ $c['server'] }} + @switch($containerMetric) + @case('memory'){{ formatBytes($c['memUsed'] ?? 0) }}@break + @case('disk'){{ formatBytes($c['diskBytes'] ?? 0) }}@break + @case('network'){{ formatBytes($c['net'] ?? 0) }}/s@break + @default{{ round($c['cpu'] ?? 0) }}% + @endswitch +
    + @endif +
    @endif + + @script + + @endscript
    From c2310a74659741eb6c1b2d99ebda71e1d489e747 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:06:08 +0000 Subject: [PATCH 009/129] docs(metrics): refresh DESIGN for disk/network/load history and fleet page --- DESIGN.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 53b058ddef..5a6d914860 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -746,10 +746,14 @@ Charts follow the application metrics implementation: - downsample long time ranges before rendering. Only add a metric if Sentinel exposes historical data for it. Current Sentinel -history endpoints store CPU and memory. Root filesystem usage is included in -the periodic push payload for threshold notifications, but it is not stored as -a historical Sentinel metric and has no history endpoint, so it cannot power a -disk-usage graph yet. +history endpoints store CPU, memory, disk, network throughput, and load average +(Sentinel PR #47 added disk, network, and load). Bulk snapshot endpoints +(`/api/summary`, `/api/containers/current`) return the latest host and +per-container values in one call for fleet-wide views. + +The fleet Metrics page (`/metrics`, top-level sidebar) aggregates these across +every team server: KPI tiles, per-metric trend cards, a threshold-colored +servers table, and a fleet-wide hottest-containers list. --- @@ -769,6 +773,7 @@ Use these as implementation references: | Dense environment table and footer | `resources/views/livewire/project/shared/environment-variable/all.blade.php` | | Standard table toolbar controls | `resources/views/components/table/*` | | Application metrics charts | `resources/views/livewire/project/shared/metrics.blade.php` | +| Fleet metrics page | `resources/views/livewire/fleet/metrics.blade.php` | | Browser terminal workspace | `resources/views/livewire/terminal/index.blade.php` | | Layer card | `resources/views/components/application/settings-section.blade.php` | | Custom dropdown | `resources/views/components/forms/listbox.blade.php` | From fbce977dbfb855d03c50b19f0a5e2974eec06689 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:07:01 +0000 Subject: [PATCH 010/129] test(metrics): assert KPI tiles, stale badge, and container toggle render --- .../Livewire/Fleet/MetricsDataTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/Feature/Livewire/Fleet/MetricsDataTest.php b/tests/Feature/Livewire/Fleet/MetricsDataTest.php index 1ef9cd5b32..ee10acade2 100644 --- a/tests/Feature/Livewire/Fleet/MetricsDataTest.php +++ b/tests/Feature/Livewire/Fleet/MetricsDataTest.php @@ -115,6 +115,25 @@ it('ranks containers across servers by the selected metric', function () { expect(array_column($component->get('topContainers'), 'id')[0])->toBe('high'); }); +it('shows the KPI tiles, a stale badge for offline servers, and the container toggle', function () { + $ok = metricsServer($this->team, $this->privateKey); + $ok->update(['name' => 'web-1']); + $dead = metricsServer($this->team, $this->privateKey); + $dead->update(['name' => 'web-2']); + + StubMetricsClient::$summaries = [ + $ok->uuid => onlineSummary(['cpu' => 95.0, 'diskPercent' => 95.0]), + $dead->uuid => null, + ]; + + Livewire::test(TestableMetrics::class) + ->assertSee('web-1') + ->assertSee('web-2') + ->assertSee('Fleet CPU') + ->assertSee('needs attention') + ->assertSee('Offline'); +}); + it('cannot see another team’s servers', function () { $otherTeam = Team::factory()->create(); // Reuse the existing key id: the factory's private key is hardcoded, so a second From 9fced09bd15ec85304d74cbb399962dbf44fae58 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:39:14 +0000 Subject: [PATCH 011/129] fix(metrics): div-grid tables, chart initial render, metrics skeleton, graph icon --- app/Livewire/Fleet/Metrics.php | 6 +- resources/css/app.css | 8 + resources/views/components/navbar.blade.php | 2 +- .../fleet/metrics-placeholder.blade.php | 17 +- .../views/livewire/fleet/metrics.blade.php | 170 ++++++++++-------- .../Livewire/Fleet/MetricsDataTest.php | 2 +- 6 files changed, 123 insertions(+), 82 deletions(-) diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index c47d282033..e29f8314ae 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -46,6 +46,9 @@ class Metrics extends Component /** @var array> */ public array $topContainers = []; + /** @var array Trend series for the charts, also embedded for first paint. */ + public array $chartData = []; + public ?string $lastUpdatedAt = null; /** @var array */ @@ -164,9 +167,10 @@ class Metrics extends Component $this->serverRows = $rows; $this->kpis = FleetMetricsAggregator::fleetKpis($rows); $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); + $this->chartData = $this->buildChartPayload($series); $this->lastUpdatedAt = now()->toIso8601String(); - $this->dispatch("refreshChartData-{$this->chartId}", $this->buildChartPayload($series)); + $this->dispatch("refreshChartData-{$this->chartId}", $this->chartData); } /** diff --git a/resources/css/app.css b/resources/css/app.css index a8eef8e2d7..a1d5a7fde1 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -3183,6 +3183,14 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: minmax(9rem, 1.15fr) minmax(14rem, 1.9fr) 7rem 6.5rem 6.5rem 5.5rem; } +.fleet-servers-table-grid { + grid-template-columns: minmax(9rem, 1.4fr) 8rem 8rem 8rem 5rem 8rem 5.5rem; +} + +.fleet-containers-table-grid { + grid-template-columns: minmax(10rem, 1.6fr) minmax(8rem, 1fr) 7rem; +} + .scheduled-executions-table-grid { grid-template-columns: 5.5rem minmax(9rem, 1fr) minmax(7rem, 0.7fr) 8.5rem 5rem minmax(12rem, 1.5fr); } diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 0722bdd076..2861a4650d 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -99,7 +99,7 @@
    - + Metrics
  • diff --git a/resources/views/livewire/fleet/metrics-placeholder.blade.php b/resources/views/livewire/fleet/metrics-placeholder.blade.php index aee0488f17..f67be9c225 100644 --- a/resources/views/livewire/fleet/metrics-placeholder.blade.php +++ b/resources/views/livewire/fleet/metrics-placeholder.blade.php @@ -1,3 +1,18 @@
    - +
    +

    Metrics

    +

    Resource usage across your servers.

    +
    + + + +
    + @foreach (['CPU', 'Memory used'] as $label) +
    + {{ $label }} + +
    + @endforeach +
    diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index f3a5857126..514ff66d4e 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -19,12 +19,12 @@
    @foreach (['24h', '7d', '30d'] as $r) + class="{{ $range === $r ? 'bg-[var(--coollabs-base)] text-black shadow-sm dark:text-fg' : 'text-neutral-500 hover:text-black dark:hover:text-fg' }} rounded px-2.5 py-1 text-[12px] font-medium transition-colors">{{ $r }} @endforeach
    @endif @@ -33,7 +33,7 @@ @if ($servers->isEmpty()) - + @else @@ -43,13 +43,15 @@ Servers online {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} @if (($kpis['needsAttention'] ?? 0) > 0) - {{ $kpis['needsAttention'] }} needs attention + {{ $kpis['needsAttention'] }} need attention + @else + All healthy @endif
    - Fleet CPU @if ($kpis['cpuApproximate'] ?? false)~@endif + Fleet CPU @if ($kpis['cpuApproximate'] ?? false)~@endif {{ round($kpis['cpuAvg'] ?? 0) }}% - @if ($kpis['cpuBusiest'] ?? null){{ $kpis['cpuBusiest']['name'] }}: {{ round($kpis['cpuBusiest']['percent']) }}%@endif + @if ($kpis['cpuBusiest'] ?? null)Busiest: {{ $kpis['cpuBusiest']['name'] }} {{ round($kpis['cpuBusiest']['percent']) }}%@endif
    Fleet memory @@ -64,20 +66,21 @@
    Fleet network {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s - rx {{ formatBytes($kpis['netRx'] ?? 0) }}/s tx {{ formatBytes($kpis['netTx'] ?? 0) }}/s + rx {{ formatBytes($kpis['netRx'] ?? 0) }}/s · tx {{ formatBytes($kpis['netTx'] ?? 0) }}/s
    Containers {{ compactNumber($kpis['containers'] ?? 0) }} + running
    {{-- Trend charts --}}
    - @foreach (['cpu' => 'CPU %', 'memory' => 'Memory used', 'network' => 'Network', 'load' => 'Load average', 'disk' => 'Disk %'] as $key => $label) + @foreach (['cpu' => 'CPU', 'memory' => 'Memory used', 'network' => 'Network throughput', 'load' => 'Load average', 'disk' => 'Disk usage'] as $key => $label)
    -
    +
    @endforeach @@ -85,31 +88,34 @@ {{-- Servers table --}} - - - - - - - - @foreach (collect($serverRows)->sortByDesc('cpu') as $row) - - - - - - - - - - @endforeach - -
    ServerCPUMemoryDiskLoadNetworkContainers
    - {{ $row['name'] }} - @unless ($row['online']) - - @endunless - @include('livewire.fleet._usage-cell', ['percent' => $row['cpu'], 'kind' => 'cpu'])@include('livewire.fleet._usage-cell', ['percent' => ($row['memTotal'] ?? 0) ? round($row['memUsed'] / $row['memTotal'] * 100, 1) : null, 'kind' => 'memory'])@include('livewire.fleet._usage-cell', ['percent' => $row['diskPercent'], 'kind' => 'disk']){{ $row['load1'] === null ? '-' : number_format($row['load1'], 2) }}{{ $row['netRx'] === null ? '-' : formatBytes($row['netRx'] + $row['netTx']).'/s' }}{{ $row['containers'] ?? '-' }}
    +
    +
    + Server + CPU + Memory + Disk + Load + Network + Containers +
    + @foreach (collect($serverRows)->sortByDesc(fn ($r) => $r['cpu'] ?? -1) as $row) +
    +
    + {{ $row['name'] }} + @unless ($row['online']) + Offline + @endunless +
    +
    @include('livewire.fleet._usage-cell', ['percent' => $row['cpu'], 'kind' => 'cpu'])
    +
    @include('livewire.fleet._usage-cell', ['percent' => ($row['memTotal'] ?? 0) ? round($row['memUsed'] / $row['memTotal'] * 100, 1) : null, 'kind' => 'memory'])
    +
    @include('livewire.fleet._usage-cell', ['percent' => $row['diskPercent'], 'kind' => 'disk'])
    +
    {{ $row['load1'] === null ? '-' : number_format($row['load1'], 2) }}
    +
    {{ $row['netRx'] === null ? '-' : formatBytes($row['netRx'] + $row['netTx']).'/s' }}
    +
    {{ $row['containers'] ?? '-' }}
    +
    + @endforeach +
    {{-- Hottest containers --}} @@ -118,32 +124,42 @@
    @foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'] as $m => $mlabel) + class="{{ $containerMetric === $m ? 'bg-[var(--coollabs-base)] text-black shadow-sm dark:text-fg' : 'text-neutral-500 hover:text-black dark:hover:text-fg' }} rounded px-2.5 py-1 text-[12px] font-medium transition-colors">{{ $mlabel }} @endforeach
    @if (empty($topContainers)) - +
    + +
    @else - - - - @foreach ($topContainers as $c) - - - - - - @endforeach - -
    ContainerServer{{ ucfirst($containerMetric) }}
    @if ($c['link']){{ $c['name'] }}@else{{ $c['name'] }}@endif{{ $c['server'] }} - @switch($containerMetric) - @case('memory'){{ formatBytes($c['memUsed'] ?? 0) }}@break - @case('disk'){{ formatBytes($c['diskBytes'] ?? 0) }}@break - @case('network'){{ formatBytes($c['net'] ?? 0) }}/s@break - @default{{ round($c['cpu'] ?? 0) }}% - @endswitch -
    +
    +
    + Container + Server + {{ ucfirst($containerMetric) }} +
    + @foreach ($topContainers as $c) +
    +
    + @if ($c['link']) + {{ $c['name'] }} + @else + {{ $c['name'] }} + @endif +
    +
    {{ $c['server'] }}
    +
    + @switch($containerMetric) + @case('memory'){{ formatBytes($c['memUsed'] ?? 0) }}@break + @case('disk'){{ formatBytes($c['diskBytes'] ?? 0) }}@break + @case('network'){{ formatBytes($c['net'] ?? 0) }}/s@break + @default{{ round($c['cpu'] ?? 0) }}% + @endswitch +
    +
    + @endforeach +
    @endif @endif @@ -156,7 +172,7 @@ const axisColor = (typeof textColor !== 'undefined') ? textColor : 'rgba(128,128,128,0.7)'; const fmtPercent = v => `${Number(Number(v).toFixed(1))}%`; - const fmtNumber = v => Number(v).toLocaleString(); + const fmtNumber = v => Number(Number(v).toFixed(2)).toLocaleString(); const fmtBytes = v => { const units = ['B', 'KB', 'MB', 'GB', 'TB']; let amount = Number(v) || 0; @@ -166,34 +182,37 @@ }; const fmtRate = v => `${fmtBytes(v)}/s`; - const baseOptions = (colors, formatter) => ({ - chart: { height: 240, type: 'area', toolbar: { show: false }, zoom: { enabled: false }, background: 'transparent' }, - series: [], + const baseOptions = (series, colors, formatter) => ({ + chart: { height: 240, type: 'area', toolbar: { show: false }, zoom: { enabled: false }, background: 'transparent', animations: { enabled: true } }, + series, colors, stroke: { curve: 'smooth', width: 2 }, fill: { type: 'gradient', gradient: { opacityFrom: 0.28, opacityTo: 0.02, stops: [0, 90, 100] } }, dataLabels: { enabled: false }, grid: { borderColor: 'rgba(128, 128, 128, 0.14)', strokeDashArray: 4 }, - legend: { show: false }, + legend: { show: series.length > 1, labels: { colors: axisColor } }, xaxis: { type: 'datetime', labels: { datetimeUTC: true, style: { colors: axisColor } } }, yaxis: { min: 0, max: max => max > 0 ? max * 1.2 : 1, forceNiceScale: true, tickAmount: 4, labels: { style: { colors: axisColor }, formatter } }, noData: { text: 'No data for this range', style: { color: axisColor } }, tooltip: { x: { format: 'yyyy-MM-dd HH:mm' } }, }); - const specs = { - cpu: { colors: ['#3b82f6'], formatter: fmtPercent }, - memory: { colors: ['#8b5cf6'], formatter: fmtBytes }, - network: { colors: ['#10b981', '#f59e0b'], formatter: fmtRate }, - load: { colors: ['#14b8a6'], formatter: fmtNumber }, - disk: { colors: ['#ef4444'], formatter: fmtPercent }, - }; + const initial = @js($chartData); + + const build = data => ({ + cpu: { series: [{ name: 'CPU %', data: data.cpu || [] }], colors: ['#3b82f6'], formatter: fmtPercent }, + memory: { series: [{ name: 'Memory', data: data.memory || [] }], colors: ['#8b5cf6'], formatter: fmtBytes }, + network: { series: [{ name: 'RX', data: data.networkRx || [] }, { name: 'TX', data: data.networkTx || [] }], colors: ['#10b981', '#f59e0b'], formatter: fmtRate }, + load: { series: [{ name: 'Load', data: data.load || [] }], colors: ['#14b8a6'], formatter: fmtNumber }, + disk: { series: [{ name: 'Disk %', data: data.disk || [] }], colors: ['#ef4444'], formatter: fmtPercent }, + }); const charts = {}; + const specs = build(initial || {}); for (const [key, spec] of Object.entries(specs)) { const el = document.getElementById(`fleet-chart-${key}`); if (! el) { continue; } - const chart = new ApexCharts(el, baseOptions(spec.colors, spec.formatter)); + const chart = new ApexCharts(el, baseOptions(spec.series, spec.colors, spec.formatter)); chart.render(); charts[key] = chart; } @@ -202,15 +221,10 @@ if (typeof checkTheme === 'function') { checkTheme(); } const data = Array.isArray(payload) ? payload[0] : payload; if (! data) { return; } - - charts.cpu?.updateSeries([{ name: 'CPU', data: data.cpu || [] }]); - charts.memory?.updateSeries([{ name: 'Memory', data: data.memory || [] }]); - charts.disk?.updateSeries([{ name: 'Disk', data: data.disk || [] }]); - charts.load?.updateSeries([{ name: 'Load', data: data.load || [] }]); - charts.network?.updateSeries([ - { name: 'RX', data: data.networkRx || [] }, - { name: 'TX', data: data.networkTx || [] }, - ]); + const next = build(data); + for (const [key, chart] of Object.entries(charts)) { + chart.updateSeries(next[key].series); + } }); })(); diff --git a/tests/Feature/Livewire/Fleet/MetricsDataTest.php b/tests/Feature/Livewire/Fleet/MetricsDataTest.php index ee10acade2..789694fb12 100644 --- a/tests/Feature/Livewire/Fleet/MetricsDataTest.php +++ b/tests/Feature/Livewire/Fleet/MetricsDataTest.php @@ -130,7 +130,7 @@ it('shows the KPI tiles, a stale badge for offline servers, and the container to ->assertSee('web-1') ->assertSee('web-2') ->assertSee('Fleet CPU') - ->assertSee('needs attention') + ->assertSee('need attention') ->assertSee('Offline'); }); From f3fdee90da04454cb3c9ec9d886c4806359793ef Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:43:37 +0000 Subject: [PATCH 012/129] feat(metrics): add disk/network/load charts to the per-server metrics page --- app/Livewire/Server/Charts.php | 22 ++++++ app/Traits/HasMetrics.php | 70 ++++++++++++++++++- .../views/livewire/server/charts.blade.php | 57 ++++++++++++++- tests/Unit/Metrics/HasMetricsMappingTest.php | 61 ++++++++++++++++ 4 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Metrics/HasMetricsMappingTest.php diff --git a/app/Livewire/Server/Charts.php b/app/Livewire/Server/Charts.php index 567034c801..654f0b9686 100644 --- a/app/Livewire/Server/Charts.php +++ b/app/Livewire/Server/Charts.php @@ -100,15 +100,37 @@ class Charts extends Component try { $cpuMetrics = $this->server->getCpuMetrics($this->interval); $memoryMetrics = $this->server->getMemoryMetrics($this->interval); + // Disk/network/load arrived in a later Sentinel; fetch each optionally so an + // older Sentinel (which 404s them) still shows CPU and memory. + $diskMetrics = $this->optionalMetric(fn () => $this->server->getDiskMetrics($this->interval)); + $loadMetrics = $this->optionalMetric(fn () => $this->server->getLoadMetrics($this->interval)); + $networkMetrics = $this->optionalMetric(fn () => $this->server->getNetworkMetrics($this->interval)) ?? []; $this->dispatch("refreshChartData-{$this->chartId}-metrics", [ 'cpuSeries' => $cpuMetrics, 'memorySeries' => $memoryMetrics, + 'diskSeries' => $diskMetrics, + 'loadSeries' => $loadMetrics, + 'networkRxSeries' => $networkMetrics['rx'] ?? [], + 'networkTxSeries' => $networkMetrics['tx'] ?? [], ]); } catch (\Throwable $e) { return handleError($e, $this); } } + /** + * Fetch a metric that may not exist on an older Sentinel; a failure yields null + * rather than breaking the whole chart refresh. + */ + private function optionalMetric(callable $fetch): mixed + { + try { + return $fetch(); + } catch (\Throwable) { + return null; + } + } + public function setInterval() { if ($this->interval <= 10) { diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php index 712f09a48b..0cba5af8ab 100644 --- a/app/Traits/HasMetrics.php +++ b/app/Traits/HasMetrics.php @@ -27,7 +27,61 @@ trait HasMetrics return convertContainerMemoryBytesToMegabytes($metrics); } + /** + * Root filesystem usage percentage over time. Disk history interleaves every + * mount, so we keep only the root mount before mapping. + */ + public function getDiskMetrics(int $mins = 5): ?array + { + $rows = $this->fetchMetricRows('disk', $mins); + if ($rows === null) { + return null; + } + + $root = collect($rows)->filter(fn ($r) => ($r['mount'] ?? null) === '/')->values()->all(); + + return $this->mapRows($root ?: $rows, 'usedPercent', $mins); + } + + /** + * Host load average (1 minute) over time. + */ + public function getLoadMetrics(int $mins = 5): ?array + { + return $this->getMetrics('load', $mins, 'load1'); + } + + /** + * Network throughput over time, as separate receive/transmit byte-rate series. + * + * @return array{rx: ?array, tx: ?array}|null + */ + public function getNetworkMetrics(int $mins = 5): ?array + { + $rows = $this->fetchMetricRows('network', $mins); + if ($rows === null) { + return null; + } + + return [ + 'rx' => $this->mapRows($rows, 'rxBytesPerSec', $mins), + 'tx' => $this->mapRows($rows, 'txBytesPerSec', $mins), + ]; + } + private function getMetrics(string $type, int $mins, string $valueField): ?array + { + return $this->mapRows($this->fetchMetricRows($type, $mins), $valueField, $mins); + } + + /** + * Fetch and decode the raw Sentinel history rows for a metric type, or null when + * metrics are disabled. Throws on a Sentinel error response (caller decides whether + * to treat that as fatal or optional). + * + * @return array>|null + */ + protected function fetchMetricRows(string $type, int $mins): ?array { $server = $this->getMetricsServer(); if (! $server->isMetricsEnabled()) { @@ -63,7 +117,21 @@ trait HasMetrics throw new \Exception($error); } - $metrics = collect(json_decode($response, true))->map(function ($metric) use ($valueField) { + return json_decode($response, true) ?: []; + } + + /** + * Map raw rows to [timestampMs, value] pairs, downsampling long ranges. + * + * @param array>|null $rows + */ + private function mapRows(?array $rows, string $valueField, int $mins): ?array + { + if ($rows === null) { + return null; + } + + $metrics = collect($rows)->map(function ($metric) use ($valueField) { return [(int) $metric['time'], (float) ($metric[$valueField] ?? 0.0)]; })->toArray(); diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index 442fc1979f..3592a1528d 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -69,6 +69,21 @@
    + +
    +
    + + +
    +
    + + +
    +
    + @script diff --git a/tests/Unit/Metrics/HasMetricsMappingTest.php b/tests/Unit/Metrics/HasMetricsMappingTest.php new file mode 100644 index 0000000000..aff3dd557c --- /dev/null +++ b/tests/Unit/Metrics/HasMetricsMappingTest.php @@ -0,0 +1,61 @@ + type => rows */ + public array $rowsByType = []; + + protected function fetchMetricRows(string $type, int $mins): ?array + { + return $this->rowsByType[$type] ?? null; + } +} + +it('keeps only the root mount for disk metrics', function () { + $d = new MetricsMappingDouble; + $d->rowsByType['disk'] = [ + ['time' => 1000, 'mount' => '/boot', 'usedPercent' => 90.0], + ['time' => 1000, 'mount' => '/', 'usedPercent' => 40.0], + ['time' => 2000, 'mount' => '/', 'usedPercent' => 42.0], + ]; + + expect($d->getDiskMetrics(5))->toBe([[1000, 40.0], [2000, 42.0]]); +}); + +it('splits network metrics into rx and tx series', function () { + $d = new MetricsMappingDouble; + $d->rowsByType['network'] = [ + ['time' => 1000, 'rxBytesPerSec' => 100.0, 'txBytesPerSec' => 10.0], + ['time' => 2000, 'rxBytesPerSec' => 200.0, 'txBytesPerSec' => 20.0], + ]; + + expect($d->getNetworkMetrics(5))->toBe([ + 'rx' => [[1000, 100.0], [2000, 200.0]], + 'tx' => [[1000, 10.0], [2000, 20.0]], + ]); +}); + +it('maps the one minute load average', function () { + $d = new MetricsMappingDouble; + $d->rowsByType['load'] = [ + ['time' => 1000, 'load1' => 0.5, 'load5' => 1.0, 'load15' => 1.5], + ]; + + expect($d->getLoadMetrics(5))->toBe([[1000, 0.5]]); +}); + +it('returns null for a metric when metrics are unavailable', function () { + $d = new MetricsMappingDouble; // rowsByType empty -> fetch returns null + + expect($d->getDiskMetrics(5))->toBeNull(); + expect($d->getNetworkMetrics(5))->toBeNull(); + expect($d->getLoadMetrics(5))->toBeNull(); +}); From 2aa656becef918c55a041e29d66dfdda971a1745 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:49:20 +0000 Subject: [PATCH 013/129] refactor(metrics): mirror analytics header/overview card, declutter KPI tiles --- app/Livewire/Fleet/Metrics.php | 20 -- .../views/livewire/fleet/metrics.blade.php | 172 ++++++++++-------- 2 files changed, 101 insertions(+), 91 deletions(-) diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index e29f8314ae..0d44d1d470 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -35,8 +35,6 @@ class Metrics extends Component #[Url(as: 'cmetric')] public string $containerMetric = 'cpu'; - public bool $live = false; - /** @var array */ public array $kpis = []; @@ -49,8 +47,6 @@ class Metrics extends Component /** @var array Trend series for the charts, also embedded for first paint. */ public array $chartData = []; - public ?string $lastUpdatedAt = null; - /** @var array */ protected array $containerMetaCache = []; @@ -91,21 +87,6 @@ class Metrics extends Component $this->loadData(); } - public function isLivePollable(): bool - { - return $this->live && $this->range === '24h'; - } - - /** - * Re-pull within the 60s cache TTL. The TTL bounds staleness, so a refresh is a - * cheap cache-hit reload rather than a global cache flush (matches the Analytics - * live toggle, which also relies on the TTL). - */ - public function refresh(): void - { - $this->loadData(); - } - /** * @return Collection */ @@ -168,7 +149,6 @@ class Metrics extends Component $this->kpis = FleetMetricsAggregator::fleetKpis($rows); $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); $this->chartData = $this->buildChartPayload($series); - $this->lastUpdatedAt = now()->toIso8601String(); $this->dispatch("refreshChartData-{$this->chartId}", $this->chartData); } diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index 514ff66d4e..db28a47285 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -1,79 +1,104 @@ -
    - {{-- Header + controls --}} -
    -
    -

    Metrics

    -

    Resource usage across your servers.

    + '', 'label' => 'All servers']], + collect($serverOptions)->map(fn ($name, $uuid) => ['value' => $uuid, 'label' => $name])->values()->all(), +); +?> +
    + + Metrics | Coolify + + + {{-- Header --}} +
    +
    +

    Metrics

    +

    + Resource usage across every server, reported by Sentinel. +

    + @if ($servers->isNotEmpty()) -
    - @if ($lastUpdatedAt) - - @endif - -
    - @foreach (['24h', '7d', '30d'] as $r) - - @endforeach +
    +
    + +
    + +
    +
    + @foreach (['24h' => '24 hours', '7d' => '7 days', '30d' => '30 days'] as $value => $label) + + @endforeach +
    -
    @endif
    @if ($servers->isEmpty()) - - - - + + + + View servers + + @else - {{-- KPI tiles --}} -
    -
    - Servers online - {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} - @if (($kpis['needsAttention'] ?? 0) > 0) - {{ $kpis['needsAttention'] }} need attention - @else - All healthy - @endif + {{-- Overview KPIs --}} + +
    +
    + Servers online + {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} + @if (($kpis['needsAttention'] ?? 0) > 0) + {{ $kpis['needsAttention'] }} need attention + @endif +
    +
    + + Fleet CPU + @if ($kpis['cpuApproximate'] ?? false) + ~ + @endif + + {{ round($kpis['cpuAvg'] ?? 0) }}% + @if ($kpis['cpuBusiest'] ?? null) + Busiest {{ $kpis['cpuBusiest']['name'] }} {{ round($kpis['cpuBusiest']['percent']) }}% + @endif +
    +
    + Fleet memory + {{ round($kpis['memPercent'] ?? 0) }}% + {{ formatBytes($kpis['memUsed'] ?? 0) }} / {{ formatBytes($kpis['memTotal'] ?? 0) }} +
    +
    + Fleet disk + {{ round($kpis['diskPercent'] ?? 0) }}% + {{ formatBytes($kpis['diskUsed'] ?? 0) }} / {{ formatBytes($kpis['diskTotal'] ?? 0) }} +
    +
    + Fleet network + {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s + ↓{{ formatBytes($kpis['netRx'] ?? 0) }} ↑{{ formatBytes($kpis['netTx'] ?? 0) }} +
    +
    + Containers + {{ compactNumber($kpis['containers'] ?? 0) }} +
    -
    - Fleet CPU @if ($kpis['cpuApproximate'] ?? false)~@endif - {{ round($kpis['cpuAvg'] ?? 0) }}% - @if ($kpis['cpuBusiest'] ?? null)Busiest: {{ $kpis['cpuBusiest']['name'] }} {{ round($kpis['cpuBusiest']['percent']) }}%@endif -
    -
    - Fleet memory - {{ round($kpis['memPercent'] ?? 0) }}% - {{ formatBytes($kpis['memUsed'] ?? 0) }} / {{ formatBytes($kpis['memTotal'] ?? 0) }} -
    -
    - Fleet disk - {{ round($kpis['diskPercent'] ?? 0) }}% - {{ formatBytes($kpis['diskUsed'] ?? 0) }} / {{ formatBytes($kpis['diskTotal'] ?? 0) }} -
    -
    - Fleet network - {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s - rx {{ formatBytes($kpis['netRx'] ?? 0) }}/s · tx {{ formatBytes($kpis['netTx'] ?? 0) }}/s -
    -
    - Containers - {{ compactNumber($kpis['containers'] ?? 0) }} - running -
    -
    + {{-- Trend charts --}}
    @@ -87,7 +112,8 @@
    {{-- Servers table --}} - +
    Server @@ -119,25 +145,29 @@ {{-- Hottest containers --}} - + -
    +
    @foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'] as $m => $mlabel) + @class([$tabButtonBase, $containerMetric === $m ? $tabButtonActive : $tabButtonInactive])> + {{ $mlabel }} + @endforeach
    @if (empty($topContainers))
    - +
    @else
    Container Server - {{ ucfirst($containerMetric) }} + {{ ['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'][$containerMetric] }}
    @foreach ($topContainers as $c)
    From 6b0c97211a57aafba4e98fa65b4113ab59a94210 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 09:18:05 +0000 Subject: [PATCH 014/129] fix(metrics): downsample history, re-rank containers without refetch, add live refresh --- app/Livewire/Fleet/Metrics.php | 14 +++++++++- app/Services/SentinelMetricsClient.php | 13 +++++++++- .../views/livewire/fleet/metrics.blade.php | 26 +++++++++++++++++++ .../Livewire/Fleet/MetricsDataTest.php | 19 ++++++++++++++ .../Services/SentinelMetricsClientTest.php | 12 +++++++++ 5 files changed, 82 insertions(+), 2 deletions(-) diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index 0d44d1d470..679fdf5593 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -35,12 +35,17 @@ class Metrics extends Component #[Url(as: 'cmetric')] public string $containerMetric = 'cpu'; + public bool $live = false; + /** @var array */ public array $kpis = []; /** @var array> */ public array $serverRows = []; + /** @var array> All fetched containers, kept so the metric toggle re-ranks without re-fetching. */ + public array $containersRaw = []; + /** @var array> */ public array $topContainers = []; @@ -78,7 +83,8 @@ class Metrics extends Component public function updatedContainerMetric(): void { - $this->loadData(); + // Re-rank the already-fetched containers; no need to re-hit Sentinel. + $this->topContainers = FleetMetricsAggregator::rankContainers($this->containersRaw, $this->containerMetric); } public function setRange(string $range): void @@ -87,6 +93,11 @@ class Metrics extends Component $this->loadData(); } + public function isLivePollable(): bool + { + return $this->live && $this->range === '24h'; + } + /** * @return Collection */ @@ -147,6 +158,7 @@ class Metrics extends Component $this->serverRows = $rows; $this->kpis = FleetMetricsAggregator::fleetKpis($rows); + $this->containersRaw = $containers; $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); $this->chartData = $this->buildChartPayload($series); diff --git a/app/Services/SentinelMetricsClient.php b/app/Services/SentinelMetricsClient.php index 5f27f1e564..820fd9c0f8 100644 --- a/app/Services/SentinelMetricsClient.php +++ b/app/Services/SentinelMetricsClient.php @@ -9,6 +9,9 @@ class SentinelMetricsClient { private string $base = 'http://localhost:8888/api'; + /** Cap on points per trend series after downsampling raw Sentinel samples. */ + private const MAX_SERIES_POINTS = 300; + public function __construct(protected Server $server) {} public static function rangeFrom(string $range): string @@ -126,10 +129,18 @@ class SentinelMetricsClient return []; } - return array_values(array_map( + $series = array_values(array_map( fn ($r) => [(int) ($r['time'] ?? 0), (float) ($r[$field] ?? 0)], $rows )); + + // Sentinel returns raw samples (thousands of points over 24h). Cap the series so + // the chart payload stays small enough to embed and morph cheaply. + if (count($series) > self::MAX_SERIES_POINTS) { + $series = downsampleLTTB($series, self::MAX_SERIES_POINTS); + } + + return $series; } /** diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index db28a47285..07bd0d9e0d 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -30,6 +30,28 @@ $serverListboxOptions = array_merge(
    + @if ($range === '24h') +
    + +
    + @endif
    @foreach (['24h' => '24 hours', '7d' => '7 days', '30d' => '30 days'] as $value => $label) - @endforeach +
    + +
    + @foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'] as $m => $mlabel) + + @endforeach +
    @if (empty($topContainers)) @@ -189,23 +194,28 @@ $serverListboxOptions = array_merge( description="Container metrics need a Sentinel build with the bulk endpoints." />
    @else -
    +
    Container Server {{ ['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'][$containerMetric] }}
    @foreach ($topContainers as $c) + @php $meta = $this->containerMeta($c['id']); @endphp
    -
    - @if ($c['link']) - {{ $c['name'] }} +
    + @if ($meta['link']) + {{ $meta['name'] }} @else - {{ $c['name'] }} + {{ $meta['name'] }} + @endif + @if ($meta['image']) + {{ $meta['image'] }} @endif
    -
    {{ $c['server'] }}
    -
    +
    {{ $c['server'] }}
    +
    @switch($containerMetric) @case('memory'){{ formatBytes($c['memUsed'] ?? 0) }}@break @case('disk'){{ formatBytes($c['diskBytes'] ?? 0) }}@break @@ -223,6 +233,9 @@ $serverListboxOptions = array_merge( @script @endscript From 701c81b28f6eeb2a8477ebd94eb80c1aec61f7b0 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 09:30:56 +0000 Subject: [PATCH 016/129] fix(metrics): custom chart tooltip so it has a background (default is stripped) --- .../views/livewire/fleet/metrics.blade.php | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index 0687779d0c..6db9550bc5 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -251,6 +251,25 @@ $serverListboxOptions = array_merge( }; const fmtRate = v => `${fmtBytes(v)}/s`; + const fmtLocalTime = ts => new Date(ts).toLocaleString(undefined, { hour12: false, timeZoneName: 'short' }); + const fmtUtcTime = ts => new Date(ts).toLocaleString(undefined, { hour12: false, timeZone: 'UTC', timeZoneName: 'short' }); + + // The app strips the default ApexCharts tooltip background (utilities.css), so + // every chart must render its own `apexcharts-tooltip-custom` markup. + const customTooltip = formatter => ({ series, dataPointIndex, w }) => { + const timestamp = w.globals.seriesX[0]?.[dataPointIndex]; + let rows = ''; + series.forEach((s, i) => { + const value = s[dataPointIndex]; + if (value === null || value === undefined) { return; } + rows += `
    ${w.globals.seriesNames[i]}: ${formatter(value)}
    `; + }); + return `
    ${rows} +
    Your time: ${fmtLocalTime(timestamp)}
    +
    UTC: ${fmtUtcTime(timestamp)}
    +
    `; + }; + const baseOptions = (series, colors, formatter) => ({ chart: { height: 240, type: 'area', toolbar: { show: false }, zoom: { enabled: false }, background: 'transparent', animations: { enabled: true } }, series, @@ -263,7 +282,7 @@ $serverListboxOptions = array_merge( xaxis: { type: 'datetime', labels: { datetimeUTC: true, style: { colors: axisColor } } }, yaxis: { min: 0, max: max => max > 0 ? max * 1.2 : 1, forceNiceScale: true, tickAmount: 4, labels: { style: { colors: axisColor }, formatter } }, noData: { text: 'No data for this range', style: { color: axisColor } }, - tooltip: { x: { format: 'yyyy-MM-dd HH:mm' } }, + tooltip: { shared: true, intersect: false, marker: { show: false }, custom: customTooltip(formatter) }, }); const initial = @js($chartData); From ab436942c94ad78f5191526e17c1ba4fbfd8936b Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Thu, 17 Sep 2026 06:30:59 +0000 Subject: [PATCH 017/129] feat(metrics): polish fleet + server metrics UI/UX Refine the /metrics fleet page and align the per-server metrics page with it. Fleet overview: - add live sparklines to the CPU/Memory/Disk/Network KPI tiles (shared axis, capped to ~40 points per sparkline) - fill the Servers online + Containers tiles (fleet-health bar, hottest container) so all six tiles read as one system - scope-aware labels: drop the "Fleet" prefix and helper copy when a single server is selected Trend charts: - hero layout: CPU spans full width, the other four form a 2x2 grid - apply the same hero layout + skeleton to the per-server page for consistency Hottest containers table: - add a rank column and a per-row magnitude bar scaled to the busiest container - fix a leaked Blade @break directive that rendered literally and forced horizontal scroll on the Network tab - keep the value column a fixed width so bars align across rows Live refresh: - add analytics-style loading feedback to the range tabs and server filter - stable wire:key on table rows + width transitions on bars so data glides on live refresh instead of snapping Generalize the shared _sparkline partial (optional color/label/format) so it serves non-traffic metrics without changing analytics behaviour. --- .../Concerns/BuildsMetricsChartPayload.php | 124 +++++++++++++- resources/css/app.css | 2 +- .../livewire/fleet/_usage-cell.blade.php | 2 +- .../fleet/metrics-placeholder.blade.php | 2 +- .../views/livewire/fleet/metrics.blade.php | 159 +++++++++++++++--- .../views/livewire/server/charts.blade.php | 58 ++++--- .../livewire/traffic/_sparkline.blade.php | 16 +- .../Fleet/MetricsChartPayloadTest.php | 55 ++++++ .../Livewire/Fleet/MetricsDataTest.php | 27 ++- 9 files changed, 378 insertions(+), 67 deletions(-) diff --git a/app/Livewire/Concerns/BuildsMetricsChartPayload.php b/app/Livewire/Concerns/BuildsMetricsChartPayload.php index c660ed49d3..8ec43ee2b8 100644 --- a/app/Livewire/Concerns/BuildsMetricsChartPayload.php +++ b/app/Livewire/Concerns/BuildsMetricsChartPayload.php @@ -6,20 +6,130 @@ use App\Services\FleetMetricsAggregator; trait BuildsMetricsChartPayload { + /** + * Sparklines read at a glance, not on close inspection, so they carry far fewer + * points than the full trend charts (industry guidance is ~10-50 per spark). All + * KPI-tile sparks share one downsampled axis so their shapes stay comparable. + */ + private const SPARK_MAX_POINTS = 40; + /** * @param array{cpu: array, memory: array, disk: array, load: array, networkRx: array, networkTx: array} $seriesByServer * @return array */ protected function buildChartPayload(array $seriesByServer): array { + $cpu = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['cpu'], 'avg'); + $memory = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['memory'], 'sum'); + $disk = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['disk'], 'avg'); + $load = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['load'], 'avg'); + $networkRx = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkRx'], 'sum'); + $networkTx = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkTx'], 'sum'); + + // Throughput spark = rx + tx per bucket. + $network = $this->combineSeries($networkRx, $networkTx); + $categories = $this->sparkCategories($cpu, $memory, $disk, $network); + + [$sparkCategories, $sparks] = $this->capSparks($categories, [ + 'cpuSpark' => $this->alignSpark($cpu, $categories), + 'memSpark' => $this->alignSpark($memory, $categories), + 'diskSpark' => $this->alignSpark($disk, $categories), + 'netSpark' => $this->alignSpark($network, $categories), + ], self::SPARK_MAX_POINTS); + return [ 'range' => $this->range, - 'cpu' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['cpu'], 'avg'), - 'memory' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['memory'], 'sum'), - 'disk' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['disk'], 'avg'), - 'load' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['load'], 'avg'), - 'networkRx' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkRx'], 'sum'), - 'networkTx' => FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkTx'], 'sum'), - ]; + 'cpu' => $cpu, + 'memory' => $memory, + 'disk' => $disk, + 'load' => $load, + 'networkRx' => $networkRx, + 'networkTx' => $networkTx, + // KPI-tile sparklines: values-only series on one shared, capped time axis. + 'sparkCategories' => $sparkCategories, + ] + $sparks; + } + + /** + * Thin every spark and the shared axis to at most $max points using the same + * evenly-spaced indices (first and last always kept), so the series stay aligned. + * + * @param array $categories + * @param array> $sparks + * @return array{0: array, 1: array>} + */ + private function capSparks(array $categories, array $sparks, int $max): array + { + $count = count($categories); + if ($count <= $max || $max < 2) { + return [$categories, $sparks]; + } + + $indices = []; + for ($i = 0; $i < $max; $i++) { + $indices[] = (int) round($i * ($count - 1) / ($max - 1)); + } + $indices = array_values(array_unique($indices)); + + $pick = fn (array $values): array => array_values(array_map(fn (int $i) => $values[$i], $indices)); + + return [$pick($categories), array_map($pick, $sparks)]; + } + + /** + * Sum two [ts, value] series bucket-by-bucket into one sorted [ts, value] series. + * + * @param array $a + * @param array $b + * @return array + */ + private function combineSeries(array $a, array $b): array + { + $map = []; + foreach ([$a, $b] as $series) { + foreach ($series as [$ts, $value]) { + $map[$ts] = ($map[$ts] ?? 0.0) + (float) $value; + } + } + ksort($map); + + return array_map(fn ($ts, $value) => [(int) $ts, $value], array_keys($map), array_values($map)); + } + + /** + * Sorted union of bucket timestamps across every series (the shared spark x-axis). + * + * @param array ...$seriesList + * @return array + */ + private function sparkCategories(array ...$seriesList): array + { + $set = []; + foreach ($seriesList as $series) { + foreach ($series as [$ts]) { + $set[(int) $ts] = true; + } + } + $categories = array_keys($set); + sort($categories); + + return $categories; + } + + /** + * Project a [ts, value] series onto the shared category axis, filling gaps with 0. + * + * @param array $series + * @param array $categories + * @return array + */ + private function alignSpark(array $series, array $categories): array + { + $map = []; + foreach ($series as [$ts, $value]) { + $map[(int) $ts] = $value; + } + + return array_map(fn ($ts) => $map[$ts] ?? 0, $categories); } } diff --git a/resources/css/app.css b/resources/css/app.css index a1d5a7fde1..a6b7fa4b17 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -3188,7 +3188,7 @@ input[type="search"]::-webkit-search-results-decoration { } .fleet-containers-table-grid { - grid-template-columns: minmax(10rem, 1.6fr) minmax(8rem, 1fr) 7rem; + grid-template-columns: 2.25rem minmax(9rem, 1.7fr) minmax(6rem, 0.9fr) minmax(8.5rem, 1fr); } .scheduled-executions-table-grid { diff --git a/resources/views/livewire/fleet/_usage-cell.blade.php b/resources/views/livewire/fleet/_usage-cell.blade.php index 55c233996a..4ef75c08be 100644 --- a/resources/views/livewire/fleet/_usage-cell.blade.php +++ b/resources/views/livewire/fleet/_usage-cell.blade.php @@ -7,6 +7,6 @@ {{ $percent === null ? '-' : round($percent).'%' }}
    diff --git a/resources/views/livewire/fleet/metrics-placeholder.blade.php b/resources/views/livewire/fleet/metrics-placeholder.blade.php index f65f4e4319..0ff339a199 100644 --- a/resources/views/livewire/fleet/metrics-placeholder.blade.php +++ b/resources/views/livewire/fleet/metrics-placeholder.blade.php @@ -22,7 +22,7 @@
    @foreach (['CPU', 'Memory used', 'Network throughput', 'Load average', 'Disk usage'] as $label) - + $label === 'CPU'])> @endforeach diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php index 6db9550bc5..b5b0856811 100644 --- a/resources/views/livewire/fleet/metrics.blade.php +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -3,6 +3,9 @@ $tabButtonBase = 'relative inline-flex h-7 items-center justify-center rounded-m $tabButtonActive = 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]'; $tabButtonInactive = 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg'; +// Tiles read "Fleet …" across all servers, but just "CPU / Memory / …" when scoped to one. +$scoped = $serverUuid !== ''; + $serverListboxOptions = array_merge( [['value' => '', 'label' => 'All servers']], collect($serverOptions)->map(fn ($name, $uuid) => ['value' => $uuid, 'label' => $name])->values()->all(), @@ -27,6 +30,10 @@ $serverListboxOptions = array_merge(
    +
    @@ -57,7 +64,8 @@ $serverListboxOptions = array_merge( @endforeach
    @@ -83,53 +91,128 @@ $serverListboxOptions = array_merge( @else {{-- Overview KPIs --}} + helper="{{ $scoped ? 'Current resource usage for the selected server.' : 'Current resource usage across the selected servers.' }}">
    Servers online {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} - @if (($kpis['needsAttention'] ?? 0) > 0) - {{ $kpis['needsAttention'] }} need attention - @endif + @php + $sTotal = max(1, (int) ($kpis['serversTotal'] ?? 0)); + $sOnline = (int) ($kpis['serversOnline'] ?? 0); + $sAttention = min($sOnline, (int) ($kpis['needsAttention'] ?? 0)); + $sHealthy = max(0, $sOnline - $sAttention); + $sOffline = max(0, $sTotal - $sOnline); + @endphp +
    +
    +
    + @if ($sHealthy > 0)
    @endif + @if ($sAttention > 0)
    @endif + @if ($sOffline > 0)
    @endif +
    +
    + {{ $sHealthy }} healthy + @if ($sAttention > 0){{ $sAttention }} attention@endif + @if ($sOffline > 0){{ $sOffline }} offline@endif +
    +
    +
    - Fleet CPU + {{ $scoped ? 'CPU' : 'Fleet CPU' }} @if ($kpis['cpuApproximate'] ?? false) ~ @endif {{ round($kpis['cpuAvg'] ?? 0) }}% - @if ($kpis['cpuBusiest'] ?? null) - Busiest {{ $kpis['cpuBusiest']['name'] }} {{ round($kpis['cpuBusiest']['percent']) }}% - @endif +
    + @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-cpu', + 'initial' => $chartData['cpuSpark'] ?? [], + 'color' => '#3b82f6', + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'cpuSpark', + 'label' => $scoped ? 'CPU' : 'Fleet CPU', + 'format' => 'percent', + ]) +
    - Fleet memory + {{ $scoped ? 'Memory' : 'Fleet memory' }} {{ round($kpis['memPercent'] ?? 0) }}% - {{ formatBytes($kpis['memUsed'] ?? 0) }} / {{ formatBytes($kpis['memTotal'] ?? 0) }} +
    + @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-memory', + 'initial' => $chartData['memSpark'] ?? [], + 'color' => '#8b5cf6', + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'memSpark', + 'label' => 'Memory used', + 'format' => 'bytes', + ]) +
    - Fleet disk + {{ $scoped ? 'Disk' : 'Fleet disk' }} {{ round($kpis['diskPercent'] ?? 0) }}% - {{ formatBytes($kpis['diskUsed'] ?? 0) }} / {{ formatBytes($kpis['diskTotal'] ?? 0) }} +
    + @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-disk', + 'initial' => $chartData['diskSpark'] ?? [], + 'color' => '#ef4444', + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'diskSpark', + 'label' => $scoped ? 'Disk' : 'Fleet disk', + 'format' => 'percent', + ]) +
    - Fleet network + {{ $scoped ? 'Network' : 'Fleet network' }} {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s - ↓{{ formatBytes($kpis['netRx'] ?? 0) }} ↑{{ formatBytes($kpis['netTx'] ?? 0) }} +
    + @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-network', + 'initial' => $chartData['netSpark'] ?? [], + 'color' => '#10b981', + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'netSpark', + 'label' => 'Network', + 'format' => 'bytes', + ]) +
    Containers {{ compactNumber($kpis['containers'] ?? 0) }} +
    +
    + @if (! empty($topContainers)) + @php + $hottest = $this->containerMeta($topContainers[0]['id']); + $hottestValue = match ($containerMetric) { + 'memory' => formatBytes($topContainers[0]['memUsed'] ?? 0), + 'disk' => formatBytes($topContainers[0]['diskBytes'] ?? 0), + 'network' => formatBytes($topContainers[0]['net'] ?? 0).'/s', + default => round($topContainers[0]['cpu'] ?? 0).'%', + }; + @endphp + {{ $hottest['name'] }} + hottest, {{ $hottestValue }} + @endif +
    +
    {{-- Trend charts --}} + {{-- CPU is the primary triage signal, so it spans full width; the other four + fleet series fall into a clean 2x2 below (no orphaned cell). --}}
    @foreach (['cpu' => 'CPU', 'memory' => 'Memory used', 'network' => 'Network throughput', 'load' => 'Load average', 'disk' => 'Disk usage'] as $key => $label) - + $key === 'cpu'])>
    @@ -151,7 +234,8 @@ $serverListboxOptions = array_merge( Containers
    @foreach (collect($serverRows)->sortByDesc(fn ($r) => $r['cpu'] ?? -1) as $row) -
    +
    {{ $row['name'] }} @@ -172,7 +256,7 @@ $serverListboxOptions = array_merge( {{-- Hottest containers --}} + helper="{{ $scoped ? 'Busiest containers on this server, ranked by the selected metric.' : 'Busiest containers across the fleet, ranked by the selected metric.' }}">
    @else + @php + $metricOf = fn ($c) => match ($containerMetric) { + 'memory' => $c['memUsed'] ?? 0, + 'disk' => $c['diskBytes'] ?? 0, + 'network' => $c['net'] ?? 0, + default => $c['cpu'] ?? 0, + }; + // Bars are scaled to the leader so the row length reads as "share of the hottest". + $maxMetric = max(1, collect($topContainers)->map($metricOf)->max()); + @endphp
    + # Container Server {{ ['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'][$containerMetric] }}
    @foreach ($topContainers as $c) - @php $meta = $this->containerMeta($c['id']); @endphp -
    + @php + $meta = $this->containerMeta($c['id']); + $pct = min(100, round($metricOf($c) / $maxMetric * 100, 1)); + $display = match ($containerMetric) { + 'memory' => formatBytes($c['memUsed'] ?? 0), + 'disk' => formatBytes($c['diskBytes'] ?? 0), + 'network' => formatBytes($c['net'] ?? 0).'/s', + default => round($c['cpu'] ?? 0).'%', + }; + @endphp +
    +
    {{ $loop->iteration }}
    @if ($meta['link']) {{ $meta['name'] }} @@ -215,13 +321,12 @@ $serverListboxOptions = array_merge( @endif
    {{ $c['server'] }}
    -
    - @switch($containerMetric) - @case('memory'){{ formatBytes($c['memUsed'] ?? 0) }}@break - @case('disk'){{ formatBytes($c['diskBytes'] ?? 0) }}@break - @case('network'){{ formatBytes($c['net'] ?? 0) }}/s@break - @default{{ round($c['cpu'] ?? 0) }}% - @endswitch +
    + + {{ $display }}
    @endforeach diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index 3592a1528d..edf970ffae 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -59,30 +59,34 @@ - -
    -
    + {{-- CPU is the primary signal, so it spans full width; the other four + charts fall into a 2x2 below (mirrors the fleet /metrics layout). --}} +
    + +
    +
    - -
    -
    + +
    +
    - -
    -
    + +
    +
    - -
    -
    + +
    +
    - -
    -
    + +
    +
    +
    @script + + diff --git a/resources/views/proxy/maintenance-page.blade.php b/resources/views/proxy/maintenance-page.blade.php new file mode 100644 index 0000000000..236e6c8706 --- /dev/null +++ b/resources/views/proxy/maintenance-page.blade.php @@ -0,0 +1,9 @@ +@extends('proxy.layout') + +@section('title', 'Under maintenance') + +@section('content') +

    503 Service Unavailable

    +

    We are doing some maintenance

    +

    This site is temporarily unavailable for maintenance. Please check back soon.

    +@endsection diff --git a/tests/Feature/Proxy/MaintenanceModeTest.php b/tests/Feature/Proxy/MaintenanceModeTest.php new file mode 100644 index 0000000000..44d8ba6d6e --- /dev/null +++ b/tests/Feature/Proxy/MaintenanceModeTest.php @@ -0,0 +1,289 @@ + Server::flushIdentityMap()); + +beforeEach(function () { + Server::flushIdentityMap(); + Event::fake([ProxyStatusChanged::class, ProxyStatusChangedUI::class]); + InstanceSettings::forceCreate(['id' => 0]); + config(['constants.ssh.mux_enabled' => false]); + fakeProxyFileUploads(); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'user' => 'cooluser', + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $this->server->proxy->type = ProxyTypes::TRAEFIK->value; + $this->server->save(); + + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first() + ?? StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); +}); + +function maintenanceApplication(array $attributes = []): Application +{ + $test = test(); + + return Application::factory()->create(array_merge([ + 'environment_id' => $test->environment->id, + 'destination_id' => $test->destination->id, + 'destination_type' => $test->destination->getMorphClass(), + 'fqdn' => 'https://app.example.com', + ], $attributes)); +} + +function maintenanceService(array $domains): Service +{ + $test = test(); + $service = Service::factory()->create([ + 'environment_id' => $test->environment->id, + 'server_id' => $test->server->id, + 'destination_id' => $test->destination->id, + 'destination_type' => $test->destination->getMorphClass(), + ]); + foreach ($domains as $name => $fqdn) { + ServiceApplication::create(['name' => $name, 'service_id' => $service->id, 'image' => 'nginx', 'fqdn' => $fqdn]); + } + + return $service; +} + +function maintenanceServer(): Server +{ + return Server::find(test()->server->id); +} + +/** + * @return array + */ +function maintenanceScriptLines(): array +{ + $lines = []; + Process::assertRan(function ($process) use (&$lines) { + array_push($lines, ...explode("\n", $process->command)); + + return true; + }); + + return $lines; +} + +it('routes the maintenance domains through Traefik with a priority just above the resource router', function () { + $application = maintenanceApplication(['fqdn' => 'https://app.example.com,http://app.example.com/api', 'is_maintenance_enabled' => true]); + + $config = Yaml::parse(maintenanceServer()->maintenanceTraefikConfiguration(collect([$application]))); + $routers = $config['http']['routers']; + $rootRule = 'Host(`app.example.com`) && PathPrefix(`/`)'; + $apiRule = 'Host(`app.example.com`) && PathPrefix(`/api`)'; + + expect($routers["coolify-maintenance-{$application->uuid}-0-http"])->toMatchArray([ + 'entryPoints' => ['http'], + 'rule' => $rootRule, + 'service' => 'coolify-maintenance-noop', + 'middlewares' => ['coolify-maintenance-headers', "coolify-maintenance-{$application->uuid}"], + 'priority' => strlen($rootRule) + 1, + ]) + ->and($routers["coolify-maintenance-{$application->uuid}-0-https"]['tls'])->toBe(['certResolver' => 'letsencrypt']) + // http:// domains get no TLS router. + ->and($routers)->not->toHaveKey("coolify-maintenance-{$application->uuid}-1-https") + ->and($routers["coolify-maintenance-{$application->uuid}-1-http"]['priority'])->toBe(strlen($apiRule) + 1) + // A resource router on a longer path of the same host keeps a higher priority. + ->and(strlen($apiRule))->toBeGreaterThan($routers["coolify-maintenance-{$application->uuid}-0-http"]['priority']) + ->and($config['http']['middlewares']["coolify-maintenance-{$application->uuid}"]['errors'])->toBe([ + 'status' => ['503'], + 'service' => 'coolify-maintenance-pages', + 'query' => "/maintenance-{$application->uuid}.html", + ]) + ->and($config['http']['middlewares']['coolify-maintenance-headers']['headers']['customResponseHeaders'])->toBe(['Retry-After' => '300']) + ->and($config['http']['services']['coolify-maintenance-noop']['loadBalancer']['servers'])->toBe([]) + ->and($config['http']['services']['coolify-maintenance-pages']['loadBalancer']['servers'])->toBe([['url' => 'http://coolify-proxy-error-pages:80']]); +}); + +it('writes Caddy maintenance blocks with the same site address as the resource labels', function (bool $forceHttps, string $domain) { + $application = maintenanceApplication(['fqdn' => $domain, 'is_maintenance_enabled' => true]); + $application->settings->update(['is_force_https_enabled' => $forceHttps]); + $application->refresh(); + $labelAddress = str(fqdnLabelsForCaddy('coolify', $application->uuid, collect([$domain]), $forceHttps)->first(fn ($label) => str_starts_with($label, 'caddy_0=')))->after('caddy_0=')->value(); + $matcher = "@coolify-maintenance-{$application->uuid}-0"; + $path = parse_url($domain, PHP_URL_PATH) ?: '/'; + + $caddyfile = maintenanceServer()->caddyBaseCaddyfile(collect([$application])); + + expect($caddyfile) + ->toStartWith("import /dynamic/*.caddy\n") + ->toContain("\n\n{$labelAddress} {\n\t{$matcher} path {$path}*\n\thandle {$matcher} {") + ->toContain("rewrite /maintenance-{$application->uuid}.html") + ->toContain('copy_response 503'); +})->with([ + 'https' => [false, 'https://app.example.com'], + 'forced https' => [true, 'https://app.example.com'], + 'http with path' => [false, 'http://app.example.com/api'], +]); + +it('keeps the base Caddyfile to the import line without maintenance resources', function () { + expect(maintenanceServer()->caddyBaseCaddyfile(collect()))->toBe("import /dynamic/*.caddy\n"); +}); + +it('skips domains that could inject proxy configuration', function () { + $application = maintenanceApplication(['fqdn' => 'https://ok.example.com,https://bad.example.com/a b,https://bad.example.com/{x},https://evil.example.com`)', 'is_maintenance_enabled' => true]); + + expect($application->maintenanceRoutes()->pluck('host')->all())->toBe(['ok.example.com']); +}); + +it('collects the domains of Docker Compose applications and services', function () { + $compose = maintenanceApplication([ + 'build_pack' => 'dockercompose', + 'fqdn' => null, + 'docker_compose_domains' => json_encode(['web' => ['domain' => 'https://web.example.com,https://www.example.com'], 'api' => ['domain' => 'https://web.example.com/api'], 'db' => ['domain' => '']]), + ]); + $service = maintenanceService(['ghost' => 'https://blog.example.com', 'admin' => 'https://admin.example.com/ghost']); + + expect($compose->maintenanceRoutes()->map(fn ($route) => $route['host'].$route['path'])->all())->toBe(['web.example.com/', 'www.example.com/', 'web.example.com/api']) + ->and($service->maintenanceRoutes()->map(fn ($route) => $route['host'].$route['path'])->all())->toBe(['blog.example.com/', 'admin.example.com/ghost']); +}); + +it('writes the pages and the Traefik routes through sudo, and removes them when maintenance ends', function () { + $application = maintenanceApplication(['is_maintenance_enabled' => true]); + $service = maintenanceService(['ghost' => 'https://blog.example.com']); + $service->forceFill(['is_maintenance_enabled' => true])->save(); + $pages = '/data/coolify/proxy/error-pages'; + + maintenanceServer()->setupMaintenancePages(); + $lines = maintenanceScriptLines(); + + expect($lines)->toContain("sudo find $pages -maxdepth 1 -name 'maintenance-*.html' ! -name 'maintenance-{$application->uuid}.html' ! -name 'maintenance-{$service->uuid}.html' -delete") + ->and(collect($lines)->contains(fn ($line) => str_starts_with($line, 'sudo docker start coolify-proxy-error-pages')))->toBeTrue() + ->and(writtenProxyFile($lines, "$pages/maintenance-{$application->uuid}.html"))->toContain('We are doing some maintenance') + ->and(writtenProxyFile($lines, "$pages/maintenance-{$service->uuid}.html"))->toContain('We are doing some maintenance') + ->and(writtenProxyFile($lines, '/data/coolify/proxy//dynamic/coolify-maintenance.yaml')) + ->toContain('app.example.com')->toContain('blog.example.com'); + + $application->forceFill(['is_maintenance_enabled' => false])->save(); + $service->forceFill(['is_maintenance_enabled' => false])->save(); + maintenanceServer()->setupMaintenancePages(); + + expect(maintenanceScriptLines())->toContain('sudo rm -f /data/coolify/proxy//dynamic/coolify-maintenance.yaml') + ->toContain("sudo find $pages -maxdepth 1 -name 'maintenance-*.html' -delete"); +}); + +it('writes the maintenance blocks into the base Caddyfile on Caddy servers', function () { + $this->server->proxy->type = ProxyTypes::CADDY->value; + $this->server->save(); + $application = maintenanceApplication(['is_maintenance_enabled' => true]); + + maintenanceServer()->setupMaintenancePages(); + + expect(writtenProxyFile(maintenanceScriptLines(), '/data/coolify/proxy/caddy/dynamic/Caddyfile')) + ->toContain("@coolify-maintenance-{$application->uuid}-0 path /*"); +}); + +it('keeps the page container for maintenance when a redirect URL replaces the catch-all page', function () { + maintenanceApplication(['is_maintenance_enabled' => true]); + $server = maintenanceServer(); + $server->proxy->redirect_url = 'https://example.com'; + $server->save(); + + $server->setupProxyErrorPageContainer(); + + expect(collect(maintenanceScriptLines())->contains(fn ($line) => str_contains($line, 'docker run -d --name coolify-proxy-error-pages')))->toBeTrue(); +}); + +it('does not touch Swarm servers', function () { + maintenanceApplication(['is_maintenance_enabled' => true]); + $this->server->settings->update(['is_swarm_manager' => true]); + + $server = maintenanceServer(); + $server->setupMaintenancePages(); + + expect($server->maintenanceResources())->toBeEmpty(); + Process::assertNothingRan(); +}); + +function maintenanceUser(Team $team, string $role): User +{ + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => $role]); + + return $user; +} + +it('lets an admin enable maintenance and save a custom page', function () { + $application = maintenanceApplication(); + $this->actingAs(maintenanceUser($this->team, 'admin')); + session(['currentTeam' => $this->team]); + + Livewire::test(Maintenance::class, ['resource' => $application]) + ->set('maintenancePage', '

    Back soon

    ') + ->call('submit') + ->assertDispatched('success') + ->set('isMaintenanceEnabled', true) + ->call('instantSaveMaintenance') + ->assertDispatched('success'); + + $application->refresh(); + expect($application->is_maintenance_enabled)->toBeTrue() + ->and($application->maintenance_page)->toBe('

    Back soon

    ') + ->and(writtenProxyFile(maintenanceScriptLines(), "/data/coolify/proxy/error-pages/maintenance-{$application->uuid}.html"))->toBe('

    Back soon

    '); +}); + +it('rejects a maintenance page larger than the limit', function () { + $application = maintenanceApplication(); + $this->actingAs(maintenanceUser($this->team, 'owner')); + session(['currentTeam' => $this->team]); + + Livewire::test(Maintenance::class, ['resource' => $application]) + ->set('maintenancePage', str_repeat('a', Server::PROXY_ERROR_PAGE_MAX_BYTES + 1)) + ->call('submit') + ->assertDispatched('error'); + + expect($application->fresh()->maintenance_page)->toBeNull(); +}); + +it('does not let a member or another team change maintenance mode', function (string $who) { + $service = maintenanceService(['ghost' => 'https://blog.example.com']); + $team = $who === 'member' ? $this->team : Team::factory()->create(); + $this->actingAs(maintenanceUser($team, $who === 'member' ? 'member' : 'owner')); + session(['currentTeam' => $team]); + + Livewire::test(Maintenance::class, ['resource' => $service]) + ->set('isMaintenanceEnabled', true) + ->call('instantSaveMaintenance') + ->assertDispatched('error') + ->set('maintenancePage', '

    Hacked

    ') + ->call('submit') + ->assertDispatched('error'); + + $service->refresh(); + expect($service->is_maintenance_enabled)->toBeFalse() + ->and($service->maintenance_page)->toBeNull(); + Process::assertNothingRan(); +})->with(['member', 'other team']); diff --git a/tests/Feature/Proxy/NonRootProxyCommandsTest.php b/tests/Feature/Proxy/NonRootProxyCommandsTest.php index f1cda7eb28..ea0c0d19e8 100644 --- a/tests/Feature/Proxy/NonRootProxyCommandsTest.php +++ b/tests/Feature/Proxy/NonRootProxyCommandsTest.php @@ -93,8 +93,10 @@ it('writes the Caddyfile through sudo', function () { StartProxy::run($this->server, async: false, force: true); + $base64 = base64_encode("import /dynamic/*.caddy\n"); + expect(nonRootProxyScriptLines()) - ->toContain("echo 'import /dynamic/*.caddy' | sudo tee /data/coolify/proxy/caddy/dynamic/Caddyfile > /dev/null"); + ->toContain("echo '{$base64}' | sudo base64 -d | sudo tee /data/coolify/proxy/caddy/dynamic/Caddyfile > /dev/null"); }); it('deploys the swarm proxy stack with an absolute compose file path', function () { diff --git a/tests/Feature/Proxy/ProxyErrorPageTest.php b/tests/Feature/Proxy/ProxyErrorPageTest.php new file mode 100644 index 0000000000..4d1f939459 --- /dev/null +++ b/tests/Feature/Proxy/ProxyErrorPageTest.php @@ -0,0 +1,286 @@ + Server::flushIdentityMap()); + +beforeEach(function () { + Server::flushIdentityMap(); + Event::fake([ProxyStatusChanged::class, ProxyStatusChangedUI::class]); + InstanceSettings::forceCreate(['id' => 0]); + config(['constants.ssh.mux_enabled' => false]); + fakeProxyFileUploads(); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'user' => 'cooluser', + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); +}); + +function useErrorPageProxy(Server $server, string $proxyType, array $proxy = []): Server +{ + $server->proxy->type = $proxyType; + foreach ($proxy as $key => $value) { + $server->proxy->{$key} = $value; + } + $server->save(); + + return $server->refresh(); +} + +/** + * @return array + */ +function errorPageScriptLines(): array +{ + $lines = []; + Process::assertRan(function ($process) use (&$lines) { + array_push($lines, ...explode("\n", $process->command)); + + return true; + }); + + return $lines; +} + +it('routes the Traefik catch-all through the errors middleware and keeps the 503', function () { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value); + + $config = Yaml::parse($server->defaultRedirectConfiguration()); + + expect($config['http']['routers']['catchall']['service'])->toBe('noop') + ->and($config['http']['routers']['catchall']['middlewares'])->toBe(['error-pages']) + ->and($config['http']['services']['noop']['loadBalancer']['servers'])->toBe([]) + ->and($config['http']['middlewares']['error-pages']['errors'])->toBe([ + 'status' => ['503'], + 'service' => 'error-pages', + 'query' => '/503.html', + ]) + ->and($config['http']['services']['error-pages']['loadBalancer']['servers']) + ->toBe([['url' => 'http://coolify-proxy-error-pages:80']]); +}); + +it('catches plain HTTP with a router without TLS', function (array $proxy, array $middlewares) { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value, $proxy); + + $routers = Yaml::parse($server->defaultRedirectConfiguration())['http']['routers']; + + // A router with `tls` matches only HTTPS, so plain HTTP needs the catchall-http router. + expect($routers['catchall']['entryPoints'])->toBe(['https']) + ->and($routers['catchall']['tls'])->toBe(['certResolver' => 'letsencrypt']) + ->and($routers['catchall-http'])->toBe([ + 'entryPoints' => ['http'], + 'service' => 'noop', + 'rule' => 'PathPrefix(`/`)', + 'priority' => -1000, + 'middlewares' => $middlewares, + ]); +})->with([ + 'error page' => [[], ['error-pages']], + 'redirect URL' => [['redirect_url' => 'https://example.com'], ['redirect-regexp']], +]); + +it('serves the error page through Caddy with a 503 status', function () { + $server = useErrorPageProxy($this->server, ProxyTypes::CADDY->value); + + expect($server->defaultRedirectConfiguration()) + ->toContain('reverse_proxy coolify-proxy-error-pages:80 {') + ->toContain('rewrite /503.html') + ->toContain('copy_response 503') + ->not->toContain('respond 503'); +}); + +it('keeps the redirect URL ahead of the error page', function (string $proxyType) { + $server = useErrorPageProxy($this->server, $proxyType, ['redirect_url' => 'https://example.com']); + $server->proxy_error_page = '

    custom

    '; + $server->save(); + + $configuration = $server->defaultRedirectConfiguration(); + $server->setupDefaultRedirect(); + $lines = errorPageScriptLines(); + + expect($configuration)->toContain('https://example.com')->not->toContain('error-pages') + ->and($lines)->toContain('sudo docker rm -f coolify-proxy-error-pages >/dev/null 2>&1 || sudo true') + ->and(implode("\n", $lines))->not->toContain('docker run'); +})->with([ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value]); + +it('removes the catch-all and the page container when the default redirect is disabled', function () { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value, ['redirect_enabled' => false]); + + $server->setupDefaultRedirect(); + $lines = errorPageScriptLines(); + + expect($lines) + ->toContain('sudo rm -f /data/coolify/proxy//dynamic/default_redirect_503.yaml') + ->toContain('sudo docker rm -f coolify-proxy-error-pages >/dev/null 2>&1 || sudo true') + ->and(writtenProxyFile($lines, '/data/coolify/proxy//dynamic/default_redirect_503.yaml'))->toBeNull(); +}); + +it('starts the page container through sudo before it writes the catch-all', function (string $proxyType, string $catchAll) { + $server = useErrorPageProxy($this->server, $proxyType); + + $server->setupDefaultRedirect(); + $lines = errorPageScriptLines(); + + $runLine = collect($lines)->search(fn (string $line) => str_contains($line, 'docker run -d --name coolify-proxy-error-pages')); + $catchAllLine = collect($lines)->search(fn (string $line) => str_contains($line, "tee $catchAll")); + + expect($runLine)->toBeInt() + ->and($catchAllLine)->toBeInt() + ->and($runLine)->toBeLessThan($catchAllLine) + ->and($lines[$runLine])->toBe('sudo docker start coolify-proxy-error-pages >/dev/null 2>&1 || sudo docker run -d --name coolify-proxy-error-pages --network coolify --restart unless-stopped --label coolify.managed=true -v /data/coolify/proxy/error-pages:/www:ro busybox:musl httpd -f -p 80 -h /www') + ->and($lines)->toContain('sudo mkdir -p /data/coolify/proxy/error-pages && sudo find /data/coolify/proxy/error-pages -user root -exec chown -h cooluser:cooluser {} + && sudo chmod o-rwx /data/coolify/proxy/error-pages') + // The page goes through scp, then sudo moves it into place. + ->and(collect($lines)->contains(fn (string $line) => preg_match('#^sudo mv -f /tmp/coolify-upload-[0-9a-f]+ /data/coolify/proxy/error-pages/503.html$#', $line) === 1))->toBeTrue() + ->and($lines)->toContain('sudo chmod 644 /data/coolify/proxy/error-pages/503.html') + ->and(writtenProxyFile($lines, '/data/coolify/proxy/error-pages/503.html'))->toContain('This site is not available at the moment') + ->and(writtenProxyFile($lines, $catchAll))->toContain('coolify-proxy-error-pages'); +})->with([ + 'traefik' => [ProxyTypes::TRAEFIK->value, '/data/coolify/proxy//dynamic/default_redirect_503.yaml'], + 'caddy' => [ProxyTypes::CADDY->value, '/data/coolify/proxy/caddy/dynamic/default_redirect_503.caddy'], +]); + +it('pushes the custom page and falls back to the default page after a reset', function () { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value); + $server->proxy_error_page = '

    Maintenance

    '; + $server->save(); + + $server->setupDefaultRedirect(); + expect(writtenProxyFile(errorPageScriptLines(), '/data/coolify/proxy/error-pages/503.html'))->toBe('

    Maintenance

    '); + + $server->proxy_error_page = null; + $server->save(); + $server->setupDefaultRedirect(); + + expect(writtenProxyFile(errorPageScriptLines(), '/data/coolify/proxy/error-pages/503.html')) + ->toContain('This site is not available at the moment') + ->toContain('https://coolify.io/docs/troubleshoot/applications/no-available-server') + ->not->toContain($server->name) + ->not->toContain($server->ip) + ->not->toContain($this->team->name); +}); + +it('keeps Swarm servers on the plain catch-all without a page container', function (string $swarmSetting) { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value); + $server->settings->update([$swarmSetting => true]); + $server->refresh(); + + $config = Yaml::parse($server->defaultRedirectConfiguration()); + $server->setupDefaultRedirect(); + + expect($config['http']['routers'])->toBe(['catchall' => [ + 'entryPoints' => ['http', 'https'], + 'service' => 'noop', + 'rule' => 'PathPrefix(`/`)', + 'tls' => ['certResolver' => 'letsencrypt'], + 'priority' => -1000, + ]]) + ->and($config['http'])->not->toHaveKey('middlewares') + ->and(array_keys($config['http']['services']))->toBe(['noop']) + ->and(implode("\n", errorPageScriptLines()))->not->toContain('coolify-proxy-error-pages'); +})->with(['is_swarm_manager', 'is_swarm_worker']); + +it('removes the page container when the proxy stops', function () { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value); + + StopProxy::run($server); + + expect(errorPageScriptLines())->toContain('sudo docker rm -f coolify-proxy-error-pages 2>/dev/null || sudo true'); +}); + +function errorPageUser(Team $team, string $role): User +{ + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => $role]); + + return $user; +} + +it('lets an admin save and reset the custom error page', function () { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value); + $this->actingAs(errorPageUser($this->team, 'admin')); + session(['currentTeam' => $this->team]); + + // The page loads the proxy configuration before the global save bar submits it. + $component = Livewire::test('server.proxy', ['server' => $server]) + ->set('proxySettings', "services:\n traefik:\n image: traefik:v3.6\n ports:\n - '80:80'\n") + ->set('customErrorPage', '

    Maintenance

    ') + ->call('submit') + ->assertDispatched('success'); + + expect($server->fresh()->proxy_error_page)->toBe('

    Maintenance

    ') + ->and(writtenProxyFile(errorPageScriptLines(), '/data/coolify/proxy/error-pages/503.html'))->toBe('

    Maintenance

    '); + + $component->call('resetCustomErrorPage') + ->assertDispatched('success') + ->assertSet('customErrorPage', null); + + expect($server->fresh()->proxy_error_page)->toBeNull(); +}); + +it('uploads a custom page larger than one shell argument', function () { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value); + $page = '

    Big

    '.str_repeat('a', 300 * 1024); + $server->proxy_error_page = $page; + $server->save(); + + $server->setupDefaultRedirect(); + $lines = errorPageScriptLines(); + + // Linux limits one shell argument to 128 KiB, so the page must not be inside a command. + expect(writtenProxyFile($lines, '/data/coolify/proxy/error-pages/503.html'))->toBe($page) + ->and(collect($lines)->max(fn (string $line) => strlen($line)))->toBeLessThan(128 * 1024); +}); + +it('rejects a custom error page larger than the limit', function () { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value); + $this->actingAs(errorPageUser($this->team, 'owner')); + session(['currentTeam' => $this->team]); + + Livewire::test('server.proxy', ['server' => $server]) + ->set('customErrorPage', str_repeat('a', Server::PROXY_ERROR_PAGE_MAX_BYTES + 1)) + ->call('submit') + ->assertDispatched('error'); + + expect($server->fresh()->proxy_error_page)->toBeNull(); + Process::assertNothingRan(); +}); + +it('does not let a member or another team change the custom error page', function (string $who) { + $server = useErrorPageProxy($this->server, ProxyTypes::TRAEFIK->value); + $server->proxy_error_page = '

    Original

    '; + $server->save(); + + $team = $who === 'member' ? $this->team : Team::factory()->create(); + $this->actingAs(errorPageUser($team, $who === 'member' ? 'member' : 'owner')); + session(['currentTeam' => $team]); + + Livewire::test('server.proxy', ['server' => $server]) + ->set('customErrorPage', '

    Hacked

    ') + ->call('submit') + ->assertDispatched('error') + ->call('resetCustomErrorPage') + ->assertDispatched('error'); + + expect($server->fresh()->proxy_error_page)->toBe('

    Original

    '); + Process::assertNothingRan(); +})->with(['member', 'other team']); diff --git a/tests/Pest.php b/tests/Pest.php index 07cfd25a16..20645621ce 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,6 +1,8 @@ command, $matches)) { + $GLOBALS['proxyFileUploads'][$matches[2]] = file_get_contents($matches[1]); + } + + return Process::result(); + }); +} + +/** + * The last content written to $file on the server, with Server::uploadProxyFile() or with + * `echo '' | base64 -d | tee $file`. + * + * @param array $lines remote script lines (see Process::assertRan) + */ +function writtenProxyFile(array $lines, string $file): ?string +{ + $content = null; + foreach ($lines as $line) { + if (preg_match('#mv -f (/tmp/coolify-upload-[0-9a-f]+) '.preg_quote($file, '#').'$#', $line, $matches)) { + $content = $GLOBALS['proxyFileUploads'][$matches[1]] ?? null; + } elseif (str_contains($line, "tee $file") && preg_match("/echo '([A-Za-z0-9+\/=]+)'/", $line, $matches)) { + $content = base64_decode($matches[1]); + } + } + + return $content; +} From 3bc90995daa52263236294448e8840c661d97650 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 7 Oct 2026 17:18:16 +0530 Subject: [PATCH 039/129] fix(railpack): stop the idle BuildKit builder and update Railpack to 0.40.1 (#12097) Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> --- .../CleanupCancelledDeployment.php | 6 +++ app/Actions/Server/CleanupDocker.php | 6 +-- app/Jobs/ApplicationDeploymentJob.php | 10 ++++- bootstrap/helpers/docker.php | 34 +++++++++++++++ config/constants.php | 4 +- docker/coolify-helper/Dockerfile | 8 ++-- other/nightly/versions.json | 2 +- ...ationDeploymentControlVarFilteringTest.php | 4 +- .../DeploymentCancellationCleanupTest.php | 13 ++++++ ...pplicationDeploymentRailpackConfigTest.php | 17 ++++++++ ...icationDeploymentRailpackEnvParityTest.php | 14 +++--- tests/Unit/ParseCommandsByLineForSudoTest.php | 14 ++++++ tests/Unit/RailpackBuilderLockTest.php | 43 +++++++++++++++++++ versions.json | 2 +- 14 files changed, 155 insertions(+), 22 deletions(-) create mode 100644 tests/Unit/RailpackBuilderLockTest.php diff --git a/app/Actions/Application/CleanupCancelledDeployment.php b/app/Actions/Application/CleanupCancelledDeployment.php index 171e660e66..5da94db3cb 100644 --- a/app/Actions/Application/CleanupCancelledDeployment.php +++ b/app/Actions/Application/CleanupCancelledDeployment.php @@ -28,6 +28,12 @@ class CleanupCancelledDeployment if (str($containerExists)->trim()->isNotEmpty()) { instant_remote_process(["docker rm -f {$deploymentUuid}"], $server); $containerStopped = true; + + // A removed helper cannot stop the Railpack builder, which keeps the memory of its builds. + if ($deployment->application?->build_pack === 'railpack') { + $helperImage = coolifyHelperImage().':'.getHelperVersion(); + instant_remote_process([railpackBuilderHelperCommand(railpackBuildxMetadataVolume($server), $helperImage, 'true')], $server, false); + } } } catch (\Throwable $e) { $firstError ??= $e; diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 201f86b367..3c0e2ba7e0 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -135,9 +135,6 @@ class CleanupDocker implements ShouldBeUnique $helperImageWithVersion = "$helperImage:$helperImageVersion"; $helperImageWithoutPrefix = 'coollabsio/coolify-helper'; $helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion"; - $buildxMetadataVolume = isDev() && $server->isLocalhost() - ? 'coolify-buildx' - : '$HOME/.docker/buildx'; $cleanupLog = []; @@ -164,7 +161,8 @@ class CleanupDocker implements ShouldBeUnique 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"', $imagePruneCmd, 'docker builder prune -af', - "docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true", + // The prune fails on a stopped builder, so start it first. + railpackBuilderHelperCommand(railpackBuildxMetadataVolume($server), $helperImageWithVersion, 'docker buildx inspect --bootstrap coolify-railpack >/dev/null 2>&1; docker buildx prune --builder coolify-railpack -af'), "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f", ]; diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 07f77318a4..c822d35866 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3350,12 +3350,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $variables; } + /** + * Without the `...` entry, the list replaces the packages that Railpack generates + * (for example libatomic1, which Node.js 25+ needs at runtime). + */ private function merge_railpack_deploy_apt_packages(Collection $variables): Collection { $packages = collect(preg_split('/\s+/', trim((string) $variables->get('RAILPACK_DEPLOY_APT_PACKAGES', ''))) ?: []) ->filter() ->values(); + if (! $packages->contains('...')) { + $packages->prepend('...'); + } + foreach (['curl', 'wget'] as $package) { if (! $packages->contains($package)) { $packages->push($package); @@ -3439,7 +3447,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ." {$this->workdir}"; return 'DOCKER_CONFIG=/root/.docker docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' - .' && '.$this->wrap_build_command_with_env_export($buildxBuildCommand); + ."\n".railpackBuilderLockedScript('('.$this->wrap_build_command_with_env_export($buildxBuildCommand).')'); } private function decode_railpack_config(string $config, string $source): array diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 059f55e333..2c1025b623 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -2109,3 +2109,37 @@ function injectDockerComposeBuildArgs(string $command, string $buildArgsString): return $modifiedCommand ?? $command; } + +/** + * Run a command that uses the Railpack BuildKit builder, then stop the builder when no other + * process uses it. An idle buildkitd keeps the memory of earlier builds until it restarts. + * + * The lock file is in the buildx metadata directory, which all helper containers of a server share. + * The script has no `&&`, `||`, pipe, or `$(`, because the sudo parser would put sudo into it. + */ +function railpackBuilderLockedScript(string $command): string +{ + return 'exec 9>/root/.docker/buildx/coolify-railpack.lock; if flock -s 9; then :; else exit $?; fi; ' + ."{$command}; status=\$?; " + .'if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack >/dev/null 2>&1; fi; ' + .'exit $status'; +} + +/** + * The buildx metadata of a server, as the `docker run -v` source that the deployment helper uses. + */ +function railpackBuildxMetadataVolume(Server $server): string +{ + return isDev() && $server->isLocalhost() ? 'coolify-buildx' : '$HOME/.docker/buildx'; +} + +/** + * Run a command for the Railpack builder from a temporary helper container, then stop the builder + * when no build uses it. + */ +function railpackBuilderHelperCommand(string $buildxMetadataVolume, string $helperImage, string $command): string +{ + $script = railpackBuilderLockedScript($command); + + return "docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImage} bash -c '{$script}' 2>/dev/null || true"; +} diff --git a/config/constants.php b/config/constants.php index e792499c35..657d393ab6 100644 --- a/config/constants.php +++ b/config/constants.php @@ -6,8 +6,8 @@ $sshCommandTimeout = (int) env('SSH_COMMAND_TIMEOUT', 3600); return [ 'coolify' => [ 'version' => env('COOLIFY_VERSION') ?: '4.4.2', - 'helper_version' => '1.0.17', - 'railpack_version' => '0.23.0', + 'helper_version' => '1.0.18', + 'railpack_version' => '0.40.1', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 1fe99f01c2..b3c068f437 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -12,9 +12,9 @@ ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases ARG NIXPACKS_VERSION=1.41.0 # https://github.com/railwayapp/railpack/releases -ARG RAILPACK_VERSION=0.23.0 -# https://github.com/jdx/mise/releases — must match railpack's pinned version (https://raw.githubusercontent.com/railwayapp/railpack/refs/heads/main/core/mise/version.txt) -ARG MISE_VERSION=2026.3.17 +ARG RAILPACK_VERSION=0.40.1 +# https://github.com/jdx/mise/releases — must match the version pinned at that railpack tag (https://raw.githubusercontent.com/railwayapp/railpack/v${RAILPACK_VERSION}/core/mise/version.txt) +ARG MISE_VERSION=2026.9.15 FROM quay.io/minio/aistor/mc:RELEASE.2026-09-06T02-44-40Z AS minio-client FROM ${BASE_IMAGE} AS base @@ -36,7 +36,7 @@ RUN apk upgrade --no-cache && \ RUN mkdir -p ~/.docker/cli-plugins # Install mise (musl build) at the path railpack expects (/tmp/railpack/mise/mise-VERSION). -# Railpack hardcodes a glibc mise download that fails on Alpine, so we pre-place a musl binary. +# Railpack skips its own mise download when this file exists. RUN mkdir -p /tmp/railpack/mise && \ if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \ curl -sSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64-musl.tar.gz" | tar xz && \ diff --git a/other/nightly/versions.json b/other/nightly/versions.json index a1f7ec3a90..3f4323c7ea 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -7,7 +7,7 @@ "version": "4.5-rc.1" }, "helper": { - "version": "1.0.17" + "version": "1.0.18" }, "sentinel": { "version": "1.0.2" diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index 076bbae527..6706bcda87 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -1372,7 +1372,7 @@ it('builds railpack variables from generic buildtime vars railpack vars and cool expect($variables->get('APP_ENV'))->toBe('production'); expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20'); expect($variables->get('RAILPACK_INSTALL_CMD'))->toBe('pnpm install --frozen-lockfile'); - expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget'); + expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... curl wget'); expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid); expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); expect($variables->has('RUNTIME_ONLY'))->toBeFalse(); @@ -1424,7 +1424,7 @@ it('builds preview railpack variables without leaking stale nixpacks vars', func expect($variables->get('PREVIEW_BUILD_FLAG'))->toBe('enabled'); expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20'); - expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget'); + expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... curl wget'); expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid); expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); expect($variables->has('PREVIEW_RUNTIME_ONLY'))->toBeFalse(); diff --git a/tests/Feature/DeploymentCancellationCleanupTest.php b/tests/Feature/DeploymentCancellationCleanupTest.php index 6912ae604a..0721fbc4d2 100644 --- a/tests/Feature/DeploymentCancellationCleanupTest.php +++ b/tests/Feature/DeploymentCancellationCleanupTest.php @@ -179,3 +179,16 @@ test('cancelling after switching to another team still cleans up and starts the assertCancelCleanupRanCorrectly($this->remoteCommands, $this->deployment); expect($queued->fresh()->status)->toBe(ApplicationDeploymentStatus::IN_PROGRESS->value); }); + +test('cancelling a Railpack deployment stops the idle Railpack builder on the build server', function (string $buildPack, bool $stopsBuilder) { + $this->application->update(['build_pack' => $buildPack]); + $this->actingAs($this->user); + + Livewire::test(DeploymentNavbar::class, ['application_deployment_queue' => $this->deployment]) + ->call('cancel'); + + expect(cancelCleanupCommandsOn($this->remoteCommands, '203.0.113.20', 'docker buildx stop coolify-railpack') !== [])->toBe($stopsBuilder); +})->with([ + 'railpack' => ['railpack', true], + 'nixpacks' => ['nixpacks', false], +]); diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php index 168e8be881..0ba3605888 100644 --- a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -260,6 +260,23 @@ it('pins docker config while running railpack buildx commands', function () { ->toContain('DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack'); }); +it('stops the railpack builder after the build when no other build uses it', function () { + [$job, $reflection] = makeRailpackDeploymentJob([ + 'uuid' => 'application-uuid', + ]); + + $command = invokeRailpackMethod($job, $reflection, 'railpack_build_command', ['coollabsio/coolify:test', collect([])]); + + $sharedLock = strpos($command, 'flock -s 9'); + $build = strpos($command, 'docker buildx build --builder coolify-railpack'); + $stop = strpos($command, 'if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack'); + + expect($sharedLock)->not->toBeFalse() + ->and($build)->toBeGreaterThan($sharedLock) + ->and($stop)->toBeGreaterThan($build) + ->and($command)->toEndWith('exit $status'); +}); + it('filters reserved docker client variables from railpack build secrets', function () { [$job, $reflection] = makeRailpackDeploymentJob([ 'uuid' => 'application-uuid', diff --git a/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php index 1dda8b8c3e..0b7d5406c9 100644 --- a/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php @@ -81,13 +81,13 @@ it('generates escaped railpack env args from resolved values and includes instal 'RAILPACK_CUSTOM_FLAG' => 'hello world', 'RAILPACK_JSON' => '{"token":"abc"}', 'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall', - 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget', + 'RAILPACK_DEPLOY_APT_PACKAGES' => '... curl wget', ]); expect($envArgs)->toContain("--env 'RAILPACK_NODE_VERSION=22'"); expect($envArgs)->toContain("--env 'RAILPACK_CUSTOM_FLAG=hello world'"); expect($envArgs)->toContain("--env 'RAILPACK_JSON={\"token\":\"abc\"}'"); expect($envArgs)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'"); - expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'"); + expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=... curl wget'"); expect($envArgs)->not->toContain('RAILPACK_NULL'); }); @@ -136,7 +136,7 @@ it('uses preview railpack environment variables for preview deployments', functi expect($variables->all())->toBe([ 'RAILPACK_PREVIEW_ONLY' => 'preview-value', - 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget', + 'RAILPACK_DEPLOY_APT_PACKAGES' => '... curl wget', ]); }); @@ -195,7 +195,7 @@ it('merges coolify env variables into railpack build variables', function () { expect($variables->all())->toBe([ 'MY_BUILD_VAR' => 'hello', - 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget', + 'RAILPACK_DEPLOY_APT_PACKAGES' => '... curl wget', 'COOLIFY_URL' => 'https://app.example.com', 'COOLIFY_FQDN' => 'app.example.com', 'COOLIFY_BRANCH' => 'main', @@ -209,7 +209,7 @@ it('merges coolify env variables into railpack build variables', function () { expect($envArgs)->toContain("--env 'COOLIFY_URL=https://app.example.com'"); expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'"); - expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'"); + expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=... curl wget'"); expect($envArgs)->not->toContain('EMPTY_VAR'); expect($envArgs)->not->toContain('NULL_VAR'); }); @@ -257,11 +257,11 @@ it('preserves user railpack deploy apt packages while adding healthcheck tools o $method->setAccessible(true); $variables = $method->invoke($job); - expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('ffmpeg curl wget'); + expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... ffmpeg curl wget'); $envArgsProperty = $reflection->getProperty('env_railpack_args'); $envArgsProperty->setAccessible(true); $envArgs = $envArgsProperty->getValue($job); - expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=ffmpeg curl wget'"); + expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=... ffmpeg curl wget'"); }); diff --git a/tests/Unit/ParseCommandsByLineForSudoTest.php b/tests/Unit/ParseCommandsByLineForSudoTest.php index 594ecbd046..a0ef50d1ad 100644 --- a/tests/Unit/ParseCommandsByLineForSudoTest.php +++ b/tests/Unit/ParseCommandsByLineForSudoTest.php @@ -863,3 +863,17 @@ test('parseCommandsByLineForSudo keeps pipes inside quoted strings unchanged', f expect($result[0])->toBe("sudo docker ps --format '{{.Names}} | {{.Image}}' | sudo grep app"); }); + +test('railpack builder helper command gets sudo only outside the helper script', function () { + $command = railpackBuilderHelperCommand('$HOME/.docker/buildx', 'coolify-helper:1.0.0', 'docker buildx prune --builder coolify-railpack -af'); + + $parsed = parseCommandsByLineForSudo(collect([$command]), $this->server)[0]; + + // The SSH user's shell must expand $HOME, so the line must not be wrapped in `sudo bash -c`. + expect($parsed)->toBe( + 'sudo docker run --rm -v $HOME/.docker/buildx:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock coolify-helper:1.0.0' + ." bash -c 'exec 9>/root/.docker/buildx/coolify-railpack.lock; if flock -s 9; then :; else exit $?; fi; docker buildx prune --builder coolify-railpack -af; status=$?;" + .' if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack >/dev/null 2>&1; fi;' + ." exit \$status' 2>/dev/null || sudo true" + ); +}); diff --git a/tests/Unit/RailpackBuilderLockTest.php b/tests/Unit/RailpackBuilderLockTest.php new file mode 100644 index 0000000000..273104d6c4 --- /dev/null +++ b/tests/Unit/RailpackBuilderLockTest.php @@ -0,0 +1,43 @@ +run(); + + return $process; + } finally { + @unlink($directory.'/builder.lock'); + rmdir($directory); + } +} + +test('a failed shared lock prevents the builder command from running', function () { + $process = runRailpackLockScript('printf build-ran', 'flock() { return 73; }; '); + + expect($process->getExitCode())->toBe(73); + expect($process->getOutput())->toBe(''); +}); + +test('the builder stops after a failed command without hiding its exit status', function () { + $process = runRailpackLockScript('(exit 42)', 'exec 3>&1; docker() { printf stopped >&3; return 0; }; '); + + expect($process->getExitCode())->toBe(42); + expect($process->getOutput())->toBe('stopped'); +}); + +test('a held shared lock prevents stopping the builder', function () { + $process = runRailpackLockScript('printf built', 'exec 8>/root/.docker/buildx/coolify-railpack.lock; flock -s 8; docker() { exit 99; }; '); + + expect($process->getExitCode())->toBe(0); + expect($process->getOutput())->toBe('built'); +}); diff --git a/versions.json b/versions.json index a1f7ec3a90..3f4323c7ea 100644 --- a/versions.json +++ b/versions.json @@ -7,7 +7,7 @@ "version": "4.5-rc.1" }, "helper": { - "version": "1.0.17" + "version": "1.0.18" }, "sentinel": { "version": "1.0.2" From 7c5b0959035660cae1e35fdd4ba1b02e57bae1e8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:35:22 +0200 Subject: [PATCH 040/129] ci: build X.Y-dev images on release branch pushes --- .../coolify-release-branch-build.yml | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 .github/workflows/coolify-release-branch-build.yml diff --git a/.github/workflows/coolify-release-branch-build.yml b/.github/workflows/coolify-release-branch-build.yml new file mode 100644 index 0000000000..8b9e4bb4e6 --- /dev/null +++ b/.github/workflows/coolify-release-branch-build.yml @@ -0,0 +1,144 @@ +name: Build Coolify Release Branch + +on: + push: + branches: ["release/v*"] + paths-ignore: + - .github/workflows/coolify-helper.yml + - .github/workflows/coolify-helper-next.yml + - .github/workflows/pr-quality.yaml + - docker/coolify-helper/Dockerfile + - docker/testing-host/Dockerfile + - templates/** + - CHANGELOG.md + +permissions: + contents: read + packages: write + +concurrency: + group: coolify-release-branch-build-${{ github.ref_name }} + cancel-in-progress: false + +env: + GITHUB_REGISTRY: ghcr.io + DOCKER_REGISTRY: docker.io + IMAGE_NAME: coollabsio/coolify + +jobs: + prepare: + runs-on: ubuntu-24.04 + outputs: + dev_version: ${{ steps.version.outputs.dev_version }} + short_sha: ${{ steps.version.outputs.short_sha }} + version: ${{ steps.version.outputs.version }} + steps: + - name: Resolve version from branch name + id: version + env: + BRANCH: ${{ github.ref_name }} + run: | + if [[ ! "${BRANCH}" =~ ^release/v([0-9]+)\.([0-9]+)$ ]]; then + echo "Invalid release branch: ${BRANCH}. Expected release/vX.Y, such as release/v4.5." + exit 1 + fi + + DEV_VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}-dev" + SHORT_SHA="${GITHUB_SHA::7}" + echo "dev_version=${DEV_VERSION}" >> "$GITHUB_OUTPUT" + echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "version=${DEV_VERSION}.${SHORT_SHA}" >> "$GITHUB_OUTPUT" + + build: + needs: prepare + strategy: + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + - arch: aarch64 + platform: linux/aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push release branch image (${{ matrix.arch }}) + uses: docker/build-push-action@v6 + with: + context: . + file: docker/production/Dockerfile + platforms: ${{ matrix.platform }} + push: true + build-args: | + COOLIFY_VERSION=${{ needs.prepare.outputs.version }} + tags: | + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.version }}-${{ matrix.arch }} + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.version }}-${{ matrix.arch }} + + publish: + needs: [prepare, build] + runs-on: ubuntu-24.04 + strategy: + matrix: + registry: [ghcr.io, docker.io] + steps: + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + if: matrix.registry == 'ghcr.io' + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + if: matrix.registry == 'docker.io' + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Publish release branch manifest on ${{ matrix.registry }} + env: + REGISTRY: ${{ matrix.registry }} + DEV_VERSION: ${{ needs.prepare.outputs.dev_version }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}" + docker buildx imagetools create \ + "${IMAGE}:${VERSION}-amd64" \ + "${IMAGE}:${VERSION}-aarch64" \ + --tag "${IMAGE}:${VERSION}" \ + --tag "${IMAGE}:${DEV_VERSION}" + + notify: + needs: [prepare, build, publish] + if: always() + runs-on: ubuntu-24.04 + steps: + - uses: sarisia/actions-status-discord@v1 + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }} + status: ${{ contains(needs.*.result, 'failure') && 'failure' || (contains(needs.*.result, 'cancelled') && 'cancelled' || 'success') }} From 51641c05b292ca6499a12f221e430c37c43b4b48 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Thu, 8 Oct 2026 18:30:44 +0530 Subject: [PATCH 041/129] feat: smart scan for git project creation (#8502) Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> --- app/Data/RepositoryDetectionResult.php | 69 +++++ .../Project/New/GithubPrivateRepository.php | 37 ++- .../New/GithubPrivateRepositoryDeployKey.php | 19 ++ .../Project/New/PublicGitRepository.php | 26 +- app/Models/Application.php | 14 +- app/Services/RepositoryDetector.php | 129 +++++++++ app/Traits/HasRepositoryDetection.php | 273 ++++++++++++++++++ .../views/components/forms/listbox.blade.php | 30 +- resources/views/components/reicon.blade.php | 1 + ...ub-private-repository-deploy-key.blade.php | 19 ++ .../new/github-private-repository.blade.php | 19 ++ .../new/partials/env-import-modal.blade.php | 31 ++ .../new/partials/smart-scan-summary.blade.php | 73 +++++ .../new/public-git-repository.blade.php | 19 ++ tests/Feature/ListboxComponentTest.php | 18 ++ tests/Feature/RepositoryScanSecurityTest.php | 98 +++++++ tests/Unit/HasRepositoryDetectionTest.php | 152 ++++++++++ tests/Unit/RepositoryDetectionResultTest.php | 120 ++++++++ tests/Unit/RepositoryDetectorTest.php | 231 +++++++++++++++ 19 files changed, 1362 insertions(+), 16 deletions(-) create mode 100644 app/Data/RepositoryDetectionResult.php create mode 100644 app/Services/RepositoryDetector.php create mode 100644 app/Traits/HasRepositoryDetection.php create mode 100644 resources/views/livewire/project/new/partials/env-import-modal.blade.php create mode 100644 resources/views/livewire/project/new/partials/smart-scan-summary.blade.php create mode 100644 tests/Feature/RepositoryScanSecurityTest.php create mode 100644 tests/Unit/HasRepositoryDetectionTest.php create mode 100644 tests/Unit/RepositoryDetectionResultTest.php create mode 100644 tests/Unit/RepositoryDetectorTest.php diff --git a/app/Data/RepositoryDetectionResult.php b/app/Data/RepositoryDetectionResult.php new file mode 100644 index 0000000000..b0f47a0f5c --- /dev/null +++ b/app/Data/RepositoryDetectionResult.php @@ -0,0 +1,69 @@ + $dockerfiles e.g. ['Dockerfile', 'apps/api/Dockerfile'] + * @param array $dockerComposeFiles e.g. ['docker-compose.yml'] + * @param array $envFiles e.g. ['.env.example' => 'KEY=val...', '.env.dist' => null] + * @param array $dockerfilePorts e.g. ['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080] + */ + public function __construct( + public array $dockerfiles = [], + public array $dockerComposeFiles = [], + public array $envFiles = [], + public array $dockerfilePorts = [], + ) {} + + public static function none(): self + { + return new self; + } + + /** + * Build pack for the files in the base directory. Files in subdirectories are often examples, + * tests, or other services, so they do not select a build pack. + */ + public function getSuggestedBuildPack(): ?BuildPackTypes + { + if ($this->baseDirectoryFile($this->dockerComposeFiles)) { + return BuildPackTypes::DOCKERCOMPOSE; + } + + if ($this->baseDirectoryFile($this->dockerfiles)) { + return BuildPackTypes::DOCKERFILE; + } + + return null; + } + + /** + * The first file that is directly in the base directory, not in a subdirectory. + * + * @param array $files + */ + public function baseDirectoryFile(array $files): ?string + { + return collect($files)->first(fn (string $file): bool => ! str_contains($file, '/')); + } + + public function hasDockerfile(): bool + { + return count($this->dockerfiles) > 0; + } + + public function hasDockerCompose(): bool + { + return count($this->dockerComposeFiles) > 0; + } + + public function hasEnvFiles(): bool + { + return count($this->envFiles) > 0; + } +} diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index cef0cf6ac7..59dce3cf59 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -7,6 +7,7 @@ use App\Models\GithubApp; use App\Models\Project; use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; +use App\Traits\HasRepositoryDetection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Route; @@ -16,6 +17,7 @@ use Livewire\Component; class GithubPrivateRepository extends Component { use AuthorizesRequests; + use HasRepositoryDetection; public $current_step = 'github_apps'; @@ -151,6 +153,8 @@ class GithubPrivateRepository extends Component $this->selected_branch_name = $this->branches->contains('name', $defaultBranch) ? $defaultBranch : data_get($this->branches, '0.name', 'main'); + + $this->detectRepository(); } protected function loadBranchByPage() @@ -173,6 +177,15 @@ class GithubPrivateRepository extends Component $this->branches = $this->branches->concat(collect($json)); } + protected function applicationForDetection(): Application + { + return $this->unsavedApplicationForDetection( + str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(), + $this->selected_branch_name, + $this->github_app, + ); + } + public function submit() { try { @@ -205,7 +218,7 @@ class GithubPrivateRepository extends Component $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); - $application = new Application([ + $application_init = [ 'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name), 'repository_project_id' => $this->selected_repository_id, 'git_repository' => str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(), @@ -219,23 +232,31 @@ class GithubPrivateRepository extends Component 'destination_type' => $destination_class, 'source_id' => $this->github_app->id, 'source_type' => $this->github_app->getMorphClass(), - ]); + ]; + + if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { + $application_init['health_check_enabled'] = false; + } + if ($this->build_pack === 'dockerfile' && $dockerfileLocation = $this->selectedDockerfileLocation()) { + $application_init['dockerfile_location'] = $dockerfileLocation; + } + if ($this->build_pack === 'dockercompose') { + $application_init['docker_compose_location'] = $this->docker_compose_location; + } + + $application = new Application($application_init); $application->save(); $application->settings->is_static = $this->is_static; $application->settings->save(); - if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { - $application->health_check_enabled = false; - } - if ($this->build_pack === 'dockercompose') { - $application['docker_compose_location'] = $this->docker_compose_location; - } $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->name = generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name, $application->uuid); $application->save(); + $this->importDetectedEnvironmentVariables($application); + return redirect()->route('project.application.configuration', [ 'application_uuid' => $application->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 6af782d1cc..d55972e97d 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -10,6 +10,7 @@ use App\Models\Project; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Support\ValidationPatterns; +use App\Traits\HasRepositoryDetection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Str; use Livewire\Component; @@ -18,6 +19,7 @@ use Spatie\Url\Url; class GithubPrivateRepositoryDeployKey extends Component { use AuthorizesRequests; + use HasRepositoryDetection; public $current_step = 'private_keys'; @@ -132,6 +134,18 @@ class GithubPrivateRepositoryDeployKey extends Component $this->current_step = 'repository'; } + protected function applicationForDetection(): Application + { + $this->get_git_source(); + + return $this->unsavedApplicationForDetection( + $this->git_repository, + $this->branch ?? 'main', + $this->git_source instanceof GithubApp || $this->git_source instanceof GitlabApp ? $this->git_source : null, + PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id), + ); + } + public function submit() { $this->authorize('create', Application::class); @@ -186,6 +200,9 @@ class GithubPrivateRepositoryDeployKey extends Component if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { $application_init['health_check_enabled'] = false; } + if ($this->build_pack === 'dockerfile' && $dockerfileLocation = $this->selectedDockerfileLocation()) { + $application_init['dockerfile_location'] = $dockerfileLocation; + } if ($this->build_pack === 'dockercompose') { $application_init['docker_compose_location'] = $this->docker_compose_location; $application_init['base_directory'] = $this->base_directory; @@ -200,6 +217,8 @@ class GithubPrivateRepositoryDeployKey extends Component $application->name = generate_random_name($application->uuid); $application->save(); + $this->importDetectedEnvironmentVariables($application); + return redirect()->route('project.application.configuration', [ 'application_uuid' => $application->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index a44aa050d9..c14c9dcd37 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -9,6 +9,7 @@ use App\Models\Project; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Support\ValidationPatterns; +use App\Traits\HasRepositoryDetection; use Carbon\Carbon; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -17,6 +18,7 @@ use Spatie\Url\Url; class PublicGitRepository extends Component { use AuthorizesRequests; + use HasRepositoryDetection; public string $repository_url; @@ -167,17 +169,17 @@ class PublicGitRepository extends Component $this->git_branch = 'master'; } $this->selectedBranch = $this->git_branch; + } catch (\Throwable $e) { if ($this->rate_limit_remaining == 0) { $this->selectedBranch = $this->git_branch; $this->branchFound = true; - return; - } - if (! $this->branchFound && $this->git_branch === 'main') { + } elseif (! $this->branchFound && $this->git_branch === 'main') { try { $this->git_branch = 'master'; $this->getBranch(); + } catch (\Throwable $e) { return handleError($e, $this); } @@ -185,6 +187,19 @@ class PublicGitRepository extends Component return handleError($e, $this); } } + if ($this->branchFound) { + $this->selectedBranch = $this->git_branch; + $this->detectRepository(); + } + } + + protected function applicationForDetection(): Application + { + return $this->unsavedApplicationForDetection( + $this->git_repository, + $this->git_branch, + $this->git_source instanceof GithubApp || $this->git_source instanceof GitlabApp ? $this->git_source : null, + ); } private function getGitSource() @@ -331,6 +346,9 @@ class PublicGitRepository extends Component if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { $application_init['health_check_enabled'] = false; } + if ($this->build_pack === 'dockerfile' && $dockerfileLocation = $this->selectedDockerfileLocation()) { + $application_init['dockerfile_location'] = $dockerfileLocation; + } if ($this->build_pack === 'dockercompose') { $application_init['docker_compose_location'] = $this->docker_compose_location; $application_init['base_directory'] = $this->base_directory; @@ -344,6 +362,8 @@ class PublicGitRepository extends Component $application->fqdn = $fqdn; $application->save(); + $this->importDetectedEnvironmentVariables($application); + return redirect()->route('project.application.configuration', [ 'application_uuid' => $application->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Models/Application.php b/app/Models/Application.php index 3b0ac88ce5..e09f96a266 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -2280,6 +2280,17 @@ class Application extends BaseModel return $reason ? '

    Reason: '.e(str($reason)->limit(300)->value()) : ''; } + /** + * One shell command that clones the repository without a checkout into $checkoutDir on the server, + * with the same credentials (GitHub App token, GitLab token, or deploy key) as a deployment. + */ + public function serverCheckoutCommand(string $uuid, string $checkoutDir): string + { + ['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: $checkoutDir); + + return str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand)); + } + /** * Commands that check out only the Compose file on the server and print it. They run on the * server itself, not in a helper container, so the checkout uses an absolute folder in /tmp. @@ -2289,8 +2300,7 @@ class Application extends BaseModel private function composeFileReadCommands(string $uuid, string $gitVersion): Collection { $checkoutDir = "/tmp/{$uuid}/checkout"; - ['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: $checkoutDir); - $cloneCommand = str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand)); + $cloneCommand = $this->serverCheckoutCommand($uuid, $checkoutDir); $workdir = rtrim($this->base_directory, '/'); $fileList = collect([".{$workdir}{$this->docker_compose_location}"]); $composeFilePath = escapeshellarg(".{$workdir}{$this->docker_compose_location}"); diff --git a/app/Services/RepositoryDetector.php b/app/Services/RepositoryDetector.php new file mode 100644 index 0000000000..371f15908e --- /dev/null +++ b/app/Services/RepositoryDetector.php @@ -0,0 +1,129 @@ +scanCommands($uuid), $this->server, timeout: 60); + } finally { + instant_remote_process(["rm -rf /tmp/{$uuid}"], $this->server, false); + } + + return $this->parseOutput((string) str($output)->trim()->afterLast("\n")); + } + + /** + * Clones the repository without a checkout and lists the files with `git ls-tree`, so no file is + * written to the disk. Each command is one line because non-root servers send every line through + * parseCommandsByLineForSudo(). + * + * @return Collection + */ + protected function scanCommands(string $uuid): Collection + { + $checkoutDir = "/tmp/{$uuid}/checkout"; + $baseDir = trim($this->baseDirectory, '/'); + if (preg_match('~(^|/)\.\.?(/|$)~', $baseDir) || ! preg_match('~^[a-zA-Z0-9_./-]*$~', $baseDir)) { + throw new RuntimeException('Invalid repository base directory.'); + } + $tree = escapeshellarg($baseDir === '' ? 'HEAD' : "HEAD:{$baseDir}"); + $maxEnvBytes = self::MAX_ENV_FILE_BYTES; + + $classifyFiles = <<<'AWK' +{ split($1, meta, " "); if (meta[1] != "100644" && meta[1] != "100755") next; path = $2; lower = tolower(path); name = lower; sub(/.*\//, "", name); if (name ~ /^dockerfile(\.[a-z0-9_-]+)?$/) kind = "dockerfile"; else if (name ~ /^(docker-)?compose\.ya?ml$/) kind = "compose"; else if (lower ~ /^\.env\.(example|sample|template|dist|local\.example)$/) kind = "env"; else next; print kind "\t" meta[3] "\t" path } +AWK; + $exposedPort = <<<'AWK' +{ sub(/\r$/, "") } toupper($1) == "EXPOSE" { split($2, p, "/"); if (p[1] ~ /^[0-9]+$/ && p[1] + 0 > 0 && p[1] + 0 <= 65535) print p[1] + 0; exit } +AWK; + $summary = '{dockerfiles: map(select(.kind == "dockerfile") | .file), dockerComposeFiles: map(select(.kind == "compose") | .file), envFiles: (map(select(.kind == "env") | {(.file): .content}) | add // {}), dockerfilePorts: (map(select(.kind == "dockerfile") | {(.file): .port}) | add // {})}'; + + $scan = 'tab=$(printf "\t"); ' + ."git -c core.quotePath=false ls-tree -r {$tree} | awk -F '\\t' ".escapeshellarg($classifyFiles) + .' | while IFS="$tab" read -r kind object file; do case "$kind" in ' + .'dockerfile) port=$(git cat-file blob "$object" | awk '.escapeshellarg($exposedPort).'); ' + .'jq -nc --arg file "$file" --argjson port "${port:-null}" \'{kind: "dockerfile", file: $file, port: $port}\' ;; ' + .'compose) jq -nc --arg file "$file" \'{kind: "compose", file: $file}\' ;; ' + ."env) git cat-file blob \"\$object\" | head -c {$maxEnvBytes} | jq -Rsc --arg file \"\$file\" '{kind: \"env\", file: \$file, content: .}' ;; " + .'esac; done | jq -sc '.escapeshellarg($summary); + + return collect([ + "rm -rf /tmp/{$uuid}", + "mkdir -p /tmp/{$uuid}", + "cd /tmp/{$uuid}", + 'sh -c '.escapeshellarg($this->application->serverCheckoutCommand($uuid, $checkoutDir)), + "cd {$checkoutDir}", + 'sh -c '.escapeshellarg($scan), + ]); + } + + protected function parseOutput(string $output): RepositoryDetectionResult + { + $data = json_decode(trim($output), true); + + if (! is_array($data)) { + return RepositoryDetectionResult::none(); + } + + foreach (['dockerfiles', 'dockerComposeFiles', 'envFiles', 'dockerfilePorts'] as $key) { + if (isset($data[$key]) && ! is_array($data[$key])) { + return RepositoryDetectionResult::none(); + } + } + + $dockerfilePorts = []; + foreach ($data['dockerfilePorts'] ?? [] as $file => $port) { + $dockerfilePorts[$file] = (is_int($port) || (is_string($port) && ctype_digit($port))) && (int) $port > 0 && (int) $port <= 65535 ? (int) $port : null; + } + + return new RepositoryDetectionResult( + dockerfiles: $this->sortByPreference(array_filter($data['dockerfiles'] ?? [], 'is_string'), '/^dockerfile$/i'), + dockerComposeFiles: $this->sortByPreference(array_filter($data['dockerComposeFiles'] ?? [], 'is_string'), '/^docker-compose\.ya?ml$/i'), + envFiles: array_filter($data['envFiles'] ?? [], fn ($content) => is_string($content) || $content === null), + dockerfilePorts: $dockerfilePorts, + ); + } + + /** + * Puts the file nearest to the base directory first. At the same depth, a file with the default + * name (for example `Dockerfile`) comes before a variant. Other files keep the scan order. + * + * @param array $files + * @return array + */ + private function sortByPreference(array $files, string $defaultNamePattern): array + { + $files = array_values($files); + usort($files, fn (string $a, string $b): int => [substr_count($a, '/'), ! preg_match($defaultNamePattern, basename($a))] + <=> [substr_count($b, '/'), ! preg_match($defaultNamePattern, basename($b))]); + + return $files; + } +} diff --git a/app/Traits/HasRepositoryDetection.php b/app/Traits/HasRepositoryDetection.php new file mode 100644 index 0000000000..7a4fced3e4 --- /dev/null +++ b/app/Traits/HasRepositoryDetection.php @@ -0,0 +1,273 @@ +authorize('create', Application::class); + try { + $application = $this->applicationForDetection(); + } catch (\Throwable $e) { + handleError($e, $this); + + return; + } + Validator::make([ + 'branch' => $application->git_branch, + 'base_directory' => $this->base_directory ?? '/', + ], [ + 'branch' => ['required', new ValidGitBranch], + 'base_directory' => ['required', 'string', 'regex:~^/?(?:[a-zA-Z0-9_.-]+/)*[a-zA-Z0-9_.-]*$~', 'not_regex:~(^|/)\.\.?(/|$)~'], + ])->validate(); + + $destination = find_resource_destination_for_current_team(data_get($this->query, 'destination')); + if (! $destination) { + $this->dispatch('error', 'Destination not found.'); + + return; + } + $this->authorize('update', $destination->server); + + $this->detectionRan = false; + $this->detectionFailed = false; + $this->applyDetectionResult(RepositoryDetectionResult::none()); + try { + $detector = new RepositoryDetector($application, $this->base_directory ?? '/', $destination->server); + $this->applyDetectionResult($detector->detect()); + } catch (\Throwable $e) { + // The exception can contain the clone command with a Git token, so do not log the message. + Log::debug('Repository detection failed', ['exception' => $e::class]); + $this->detectionFailed = true; + $this->dispatch('error', 'Smart Scan could not read the repository. Check the repository access and the branch.'); + } + $this->detectionRan = true; + } + + /** + * Builds an application that is not saved, only to generate the clone command for the scan. + */ + protected function unsavedApplicationForDetection(string $repository, string $branch, GithubApp|GitlabApp|null $source = null, ?PrivateKey $privateKey = null): Application + { + $application = (new Application)->forceFill([ + 'git_repository' => $repository, + 'git_branch' => $branch, + 'git_commit_sha' => 'HEAD', + 'private_key_id' => $privateKey?->id, + ]); + $application->setRelation('settings', (new ApplicationSetting)->forceFill([ + 'is_git_shallow_clone_enabled' => true, + 'is_git_submodules_enabled' => false, + ])); + $application->setRelation('source', $source); + $application->setRelation('private_key', $privateKey); + + return $application; + } + + protected function importDetectedEnvironmentVariables(Application $application): void + { + if (! $this->envImported || empty($this->envExampleVars)) { + return; + } + Validator::make(['variables' => $this->envExampleVars], [ + 'variables' => ['array'], + 'variables.*' => ['nullable', 'string'], + ])->validate(); + $application->getConnection()->transaction(function () use ($application): void { + foreach ($this->envExampleVars as $key => $value) { + if (! preg_match('/^[A-Z_][A-Z0-9_]*$/i', (string) $key)) { + continue; + } + EnvironmentVariable::create([ + 'key' => $key, + 'value' => $value, + 'resourceable_type' => $application->getMorphClass(), + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + } + }); + } + + protected function applyDetectionResult(RepositoryDetectionResult $result): void + { + $this->detectedDockerfiles = $result->dockerfiles; + $this->detectedDockerComposeFiles = $result->dockerComposeFiles; + $this->dockerfilePorts = $result->dockerfilePorts; + + $this->selectedDockerfile = null; + $this->selectedDockerComposeFile = null; + $this->detectedPort = null; + $this->detectedEnvFiles = []; + $this->parsedEnvFiles = []; + $this->selectedEnvFile = null; + $this->envExampleVars = []; + $this->envImported = false; + + $this->suggestedBuildPack = $result->getSuggestedBuildPack()?->value; + if ($this->suggestedBuildPack && $this->suggestedBuildPack !== $this->build_pack) { + $this->build_pack = $this->suggestedBuildPack; + $this->updatedBuildPack(); + } + + if ($result->hasDockerfile()) { + $this->selectedDockerfile = $result->dockerfiles[0]; + $port = $result->dockerfilePorts[$this->selectedDockerfile] ?? null; + $this->port = $port ?? 3000; + $this->detectedPort = $port; + } + + if ($result->hasDockerCompose()) { + $this->selectedDockerComposeFile = $result->dockerComposeFiles[0]; + $this->docker_compose_location = '/'.$this->selectedDockerComposeFile; + } + + if ($result->hasEnvFiles()) { + $this->detectedEnvFiles = array_keys($result->envFiles); + $this->parsedEnvFiles = []; + foreach ($result->envFiles as $filename => $content) { + $parsed = $content !== null ? parseEnvFormatToArray($content) : []; + // parseEnvFormatToArray returns ['KEY' => ['value' => ..., 'comment' => ...]]. + // Flatten to a plain ['KEY' => 'value'] map for the import form. + $this->parsedEnvFiles[$filename] = collect($parsed) + ->map(fn ($item) => is_array($item) ? (string) ($item['value'] ?? '') : (string) $item) + ->all(); + } + $this->selectedEnvFile = $this->detectedEnvFiles[0]; + $this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile] ?? []; + } + } + + /** + * Repository path without a leading slash, so a typed path matches the detected paths. + */ + protected function normalizeRepositoryFilePath(?string $path): ?string + { + $path = ltrim(trim((string) $path), '/'); + + return $path === '' ? null : $path; + } + + /** + * Dockerfile location for the new application: a detected file or a path the user typed. + */ + protected function selectedDockerfileLocation(): ?string + { + $path = $this->normalizeRepositoryFilePath($this->selectedDockerfile); + if ($path === null) { + return null; + } + $location = '/'.$path; + Validator::make( + ['selectedDockerfile' => $location], + ['selectedDockerfile' => ValidationPatterns::filePathRules()], + ValidationPatterns::filePathMessages('selectedDockerfile', 'Dockerfile'), + )->validate(); + + return $location; + } + + public function updatedSelectedDockerfile(): void + { + $this->selectedDockerfile = $this->normalizeRepositoryFilePath($this->selectedDockerfile); + $this->resetErrorBag('selectedDockerfile'); + $this->selectedDockerfileLocation(); + + $port = $this->dockerfilePorts[$this->selectedDockerfile ?? ''] ?? null; + $this->port = $port ?? 3000; + $this->detectedPort = $port; + } + + public function updatedSelectedDockerComposeFile(): void + { + $this->selectedDockerComposeFile = $this->normalizeRepositoryFilePath($this->selectedDockerComposeFile); + + if ($this->selectedDockerComposeFile) { + $this->docker_compose_location = '/'.$this->selectedDockerComposeFile; + } + } + + public function updatedSelectedEnvFile(): void + { + if ($this->selectedEnvFile && isset($this->parsedEnvFiles[$this->selectedEnvFile])) { + $this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile]; + $this->envImported = false; + } + } + + public function confirmEnvImport(): void + { + $this->envImported = true; + } + + public function clearEnvVars(): void + { + $this->envImported = false; + $this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile] ?? []; + } +} diff --git a/resources/views/components/forms/listbox.blade.php b/resources/views/components/forms/listbox.blade.php index 2f382fd1c2..58cb9b406b 100644 --- a/resources/views/components/forms/listbox.blade.php +++ b/resources/views/components/forms/listbox.blade.php @@ -23,6 +23,7 @@ 'searchable' => false, // true = a search field filters the options 'searchPlaceholder' => 'Search…', 'searchEmptyText' => 'No matching options', + 'allowCustom' => false, // true (with searchable) = the search text can be chosen as a custom value ]) @php @@ -92,9 +93,15 @@ return this.options.filter((option) => !option.header && [option.label, option.value, option.description] .some((text) => String(text ?? '').toLowerCase().includes(query))); }, + get customOption() { + const query = this.query.trim(); + if (!@js($allowCustom) || query === '' || this.options.some((option) => !option.header && String(option.value) === query)) return null; + return { value: query, label: query }; + }, chooseOnlyMatch() { const matches = this.visibleOptions.filter((option) => !option.disabled); if (matches.length === 1) this.choose(matches[0]); + else if (this.customOption) this.choose(this.customOption); }, @else get visibleOptions() { @@ -126,7 +133,8 @@ }, get current() { const found = this.options.find((option) => String(option.value) === String(this.value)); - return found ? found.label : @js($placeholder); + if (found) return found.label; + return @js($allowCustom) && this.value ? String(this.value) : @js($placeholder); }, get title() { return this.current; @@ -237,7 +245,7 @@
    @endif -
    @if ($searchable) @@ -272,6 +280,14 @@
    + @if ($searchable && $allowCustom) + + @endif @if ($searchable)
    @endif @@ -296,7 +312,7 @@
    @endif -
    @if ($searchable) @@ -329,6 +345,14 @@
    + @if ($searchable && $allowCustom) + + @endif @if ($searchable)
    @endif diff --git a/resources/views/components/reicon.blade.php b/resources/views/components/reicon.blade.php index a611869feb..71d89016c8 100644 --- a/resources/views/components/reicon.blade.php +++ b/resources/views/components/reicon.blade.php @@ -8,6 +8,7 @@ $icons = [ 'fire' => '', 'cloud' => '', + 'brain' => '', 'code' => '', 'analytics' => '', 'mail' => '', diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index b0dc41bb4d..eea738ef61 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -103,6 +103,25 @@ @endif + + @include('livewire.project.new.partials.smart-scan-summary') + + {{-- Dockerfile: pick a detected file or type a path --}} + @if ($build_pack === 'dockerfile') + + @endif + + {{-- Docker Compose file selector: pick a detected file or type a path --}} + @if ($build_pack === 'dockercompose' && count($detectedDockerComposeFiles) > 0) + + @endif
    diff --git a/resources/views/livewire/project/new/github-private-repository.blade.php b/resources/views/livewire/project/new/github-private-repository.blade.php index 9ca9c2fec3..38bd93ded9 100644 --- a/resources/views/livewire/project/new/github-private-repository.blade.php +++ b/resources/views/livewire/project/new/github-private-repository.blade.php @@ -104,6 +104,8 @@ Continue
    + @include('livewire.project.new.partials.smart-scan-summary') +
    + {{-- Dockerfile: pick a detected file or type a path --}} + @if ($build_pack === 'dockerfile') + + @endif + + {{-- Docker Compose file selector: pick a detected file or type a path --}} + @if ($build_pack === 'dockercompose' && count($detectedDockerComposeFiles) > 0) + + @endif + @if ($build_pack === 'dockercompose')
    + + {{ $envImported ? 'Edit' : 'Review' }} + + +
    + @if (count($detectedEnvFiles) > 1) + + @endif +
    + @foreach ($envExampleVars as $key => $value) +
    + {{ $key }} + +
    + @endforeach +
    +
    + + + @if ($envImported) + Remove import + @endif + + {{ $envImported ? 'Update' : 'Import' }} {{ count($envExampleVars) }} {{ Str::plural('variable', count($envExampleVars)) }} + + + diff --git a/resources/views/livewire/project/new/partials/smart-scan-summary.blade.php b/resources/views/livewire/project/new/partials/smart-scan-summary.blade.php new file mode 100644 index 0000000000..690bba4303 --- /dev/null +++ b/resources/views/livewire/project/new/partials/smart-scan-summary.blade.php @@ -0,0 +1,73 @@ +{{-- + Smart Scan result inside the build configuration, as a compact divided list: the build pack, + the Dockerfile port, and the env file import. The scan button sends the current base directory, + so the user can scan a subdirectory after changing it. +--}} +@php + $buildPackLabels = ['dockercompose' => 'Docker Compose', 'dockerfile' => 'Dockerfile']; + $suggestedFile = match ($suggestedBuildPack) { + 'dockercompose' => $detectedDockerComposeFiles[0] ?? null, + 'dockerfile' => $detectedDockerfiles[0] ?? null, + default => null, + }; + $nestedFiles = collect([ + count($detectedDockerfiles) ? count($detectedDockerfiles).' '.Str::plural('Dockerfile', count($detectedDockerfiles)) : null, + count($detectedDockerComposeFiles) ? count($detectedDockerComposeFiles).' '.Str::plural('Compose file', count($detectedDockerComposeFiles)) : null, + ])->filter()->join(' and '); + $showPort = $detectionRan && $build_pack === 'dockerfile' && in_array($selectedDockerfile, $detectedDockerfiles, true); +@endphp + +
    +
    +

    + Smart Scan +

    + + + {{ $detectionRan ? 'Scan again' : 'Scan repository' }} + +
    + +
    + +
    + +
    + @if (! $detectionRan) + + @elseif ($detectionFailed) + + @elseif ($suggestedBuildPack) + + @elseif ($nestedFiles !== '') + + @else + + @endif + + @if ($showPort) + @if ($detectedPort) + + @else + + @endif + @endif + + @if ($detectionRan && count($envExampleVars) > 0) +
    + + @include('livewire.project.new.partials.env-import-modal') +
    + @endif +
    +
    diff --git a/resources/views/livewire/project/new/public-git-repository.blade.php b/resources/views/livewire/project/new/public-git-repository.blade.php index 137d880eed..2d3d3ba596 100644 --- a/resources/views/livewire/project/new/public-git-repository.blade.php +++ b/resources/views/livewire/project/new/public-git-repository.blade.php @@ -56,6 +56,8 @@ @endif + @include('livewire.project.new.partials.smart-scan-summary') +
    + {{-- Dockerfile: pick a detected file or type a path --}} + @if ($build_pack === 'dockerfile') + + @endif + + {{-- Docker Compose file selector: pick a detected file or type a path --}} + @if ($build_pack === 'dockercompose' && count($detectedDockerComposeFiles) > 0) + + @endif + @if ($build_pack === 'dockercompose')
    + BLADE); + + expect($html) + ->toContain('x-if="customOption"') + ->toContain('!customOption'); +}); + +test('listbox without allowCustom offers no custom option', function () { + $html = Blade::render(<<<'BLADE' + + BLADE); + + expect($html)->not->toContain('x-if="customOption"'); +}); + test('listbox without searchable renders no search field', function () { $html = Blade::render(<<<'BLADE' diff --git a/tests/Feature/RepositoryScanSecurityTest.php b/tests/Feature/RepositoryScanSecurityTest.php new file mode 100644 index 0000000000..4d1d441736 --- /dev/null +++ b/tests/Feature/RepositoryScanSecurityTest.php @@ -0,0 +1,98 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->actingAs($this->user); + session(['currentTeam' => ['id' => $this->team->id]]); +}); + +test('members cannot start scans in any creation flow', function (string $componentClass) { + $this->team->members()->attach($this->user->id, ['role' => 'member']); + $component = new $componentClass; + expect(fn () => $component->detectRepository())->toThrow(AuthorizationException::class); +})->with([PublicGitRepository::class, GithubPrivateRepository::class, GithubPrivateRepositoryDeployKey::class]); + +test('owners cannot use scan base directory traversal', function () { + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $component = new PublicGitRepository; + $component->git_repository = 'https://github.com/test/repo'; + $component->base_directory = '/../outside'; + expect(fn () => $component->detectRepository())->toThrow(ValidationException::class); +}); + +test('scan does not run on a destination from another team', function () { + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $otherServer = Server::factory()->create(['team_id' => Team::factory()->create()->id]); + $otherDestination = $otherServer->standaloneDockers()->firstOrFail(); + + Livewire\Livewire::test(RepositoryScanStateTestComponent::class) + ->set('git_repository', 'https://github.com/test/repo') + ->set('query', ['destination' => $otherDestination->uuid]) + ->call('detectRepository') + ->assertDispatched('error', 'Destination not found.') + ->assertSet('detectionRan', false); +}); + +test('environment import stores valid keys and skips malformed keys', function () { + $application = Application::factory()->create(['build_pack' => 'dockerfile']); + $component = new class extends PublicGitRepository + { + public function importInto(Application $application): void + { + $this->importDetectedEnvironmentVariables($application); + } + }; + $component->envImported = true; + $component->envExampleVars = ['VALID_KEY' => 'value', 'invalid.key' => 'ignored', '1BAD' => 'ignored']; + $component->importInto($application); + expect($application->environment_variables()->pluck('value', 'key')->all())->toBe(['VALID_KEY' => 'value']); +}); + +class RepositoryScanStateTestComponent extends PublicGitRepository +{ + public function mount() {} + + public function render() + { + return '
    '; + } +} + +test('clients cannot change the detected Dockerfile allowlist', function () { + expect(fn () => Livewire\Livewire::test(RepositoryScanStateTestComponent::class) + ->set('detectedDockerfiles', ['../../outside/Dockerfile'])) + ->toThrow(CannotUpdateLockedPropertyException::class); +}); + +test('deploy key scans clone with the key, like a deployment', function () { + $application = (new Application)->forceFill(['git_repository' => 'git@github.com:test/private.git', 'git_branch' => 'main', 'git_commit_sha' => 'HEAD', 'private_key_id' => 5]); + $application->setRelation('settings', (new ApplicationSetting)->forceFill(['is_git_shallow_clone_enabled' => true, 'is_git_submodules_enabled' => false])); + $application->setRelation('source', null); + $application->setRelation('private_key', (new PrivateKey)->forceFill(['private_key' => 'test-key'])); + $detector = new RepositoryDetector($application, '/', new Server); + + $clone = (new ReflectionMethod($detector, 'scanCommands'))->invoke($detector, 'scan-test')->get(3); + + expect($clone)->toStartWith('sh -c ') + ->toContain(base64_encode('test-key')) + ->toContain('-i /root/.ssh/id_rsa_coolify_scan-test') + ->toContain('git@github.com:test/private.git'); +}); diff --git a/tests/Unit/HasRepositoryDetectionTest.php b/tests/Unit/HasRepositoryDetectionTest.php new file mode 100644 index 0000000000..a06c778c97 --- /dev/null +++ b/tests/Unit/HasRepositoryDetectionTest.php @@ -0,0 +1,152 @@ +unsavedApplicationForDetection('test/repo', 'main'); + } + + public function updatedBuildPack(): void {} + + public function resetErrorBag($field = null): void {} + + public function dockerfileLocation(): ?string + { + return $this->selectedDockerfileLocation(); + } + + public function apply(RepositoryDetectionResult $result): void + { + $this->applyDetectionResult($result); + } +} + +test('env vars are flattened to plain key value strings for the import form', function () { + $host = new RepositoryDetectionHost; + + $host->apply(new RepositoryDetectionResult( + envFiles: [ + '.env.example' => "APP_NAME=MyApp\nAPP_DEBUG=true\nAPP_URL=\"https://example.com\"", + ], + )); + + expect($host->envExampleVars)->toBe([ + 'APP_NAME' => 'MyApp', + 'APP_DEBUG' => 'true', + 'APP_URL' => 'https://example.com', + ]); + + // No value should be an array (which would render as "[object Object]" in the UI). + foreach ($host->envExampleVars as $value) { + expect($value)->toBeString(); + } +}); + +test('switching the selected env file keeps values flat', function () { + $host = new RepositoryDetectionHost; + + $host->apply(new RepositoryDetectionResult( + envFiles: [ + '.env.example' => 'FOO=bar', + '.env.sample' => 'BAZ=qux', + ], + )); + + $host->selectedEnvFile = '.env.sample'; + $host->updatedSelectedEnvFile(); + + expect($host->envExampleVars)->toBe(['BAZ' => 'qux']); +}); + +test('empty scan preserves the chosen build pack and clears previous results', function () { + $host = new RepositoryDetectionHost; + $host->apply(new RepositoryDetectionResult(envFiles: ['.env.example' => 'FOO=bar'])); + $host->envImported = true; + $host->apply(RepositoryDetectionResult::none()); + + expect($host->build_pack)->toBe('railpack') + ->and($host->envExampleVars)->toBe([]) + ->and($host->detectedEnvFiles)->toBe([]) + ->and($host->selectedEnvFile)->toBeNull() + ->and($host->envImported)->toBeFalse(); +}); + +test('switching to a Dockerfile without EXPOSE resets the detected port', function () { + $host = new RepositoryDetectionHost; + $host->apply(new RepositoryDetectionResult( + dockerfiles: ['Dockerfile', 'Dockerfile.worker'], + dockerfilePorts: ['Dockerfile' => 8080, 'Dockerfile.worker' => null], + )); + $host->selectedDockerfile = 'Dockerfile.worker'; + $host->updatedSelectedDockerfile(); + + expect($host->port)->toBe(3000)->and($host->detectedPort)->toBeNull(); +}); + +test('a typed Dockerfile path that was not detected is kept', function () { + $host = new RepositoryDetectionHost; + $host->apply(new RepositoryDetectionResult(dockerfiles: ['Dockerfile'], dockerfilePorts: ['Dockerfile' => 8080])); + $host->selectedDockerfile = ' /docker/prod.Dockerfile '; + $host->updatedSelectedDockerfile(); + + expect($host->selectedDockerfile)->toBe('docker/prod.Dockerfile') + ->and($host->dockerfileLocation())->toBe('/docker/prod.Dockerfile') + ->and($host->port)->toBe(3000); +}); + +test('a typed Dockerfile path with unsafe characters is rejected', function () { + $host = new RepositoryDetectionHost; + $host->selectedDockerfile = 'Dockerfile; rm -rf /'; + + expect(fn () => $host->updatedSelectedDockerfile())->toThrow(ValidationException::class); +}); + +test('an empty Dockerfile selection keeps the default location', function () { + $host = new RepositoryDetectionHost; + $host->selectedDockerfile = ' '; + $host->updatedSelectedDockerfile(); + + expect($host->selectedDockerfile)->toBeNull()->and($host->dockerfileLocation())->toBeNull(); +}); + +test('a typed Docker Compose path sets the compose location', function () { + $host = new RepositoryDetectionHost; + $host->apply(new RepositoryDetectionResult(dockerComposeFiles: ['docker-compose.yml'])); + $host->selectedDockerComposeFile = '/deploy/compose.prod.yaml'; + $host->updatedSelectedDockerComposeFile(); + + expect($host->selectedDockerComposeFile)->toBe('deploy/compose.prod.yaml') + ->and($host->docker_compose_location)->toBe('/deploy/compose.prod.yaml'); +}); + +test('nested build files do not change the build pack', function () { + $host = new RepositoryDetectionHost; + $host->apply(new RepositoryDetectionResult( + dockerfiles: ['apps/api/Dockerfile'], + dockerComposeFiles: ['examples/demo/docker-compose.yml'], + )); + + expect($host->build_pack)->toBe('railpack') + ->and($host->suggestedBuildPack)->toBeNull() + ->and($host->detectedDockerComposeFiles)->toBe(['examples/demo/docker-compose.yml']); +}); diff --git a/tests/Unit/RepositoryDetectionResultTest.php b/tests/Unit/RepositoryDetectionResultTest.php new file mode 100644 index 0000000000..34bc2d189d --- /dev/null +++ b/tests/Unit/RepositoryDetectionResultTest.php @@ -0,0 +1,120 @@ +dockerfiles)->toBe([]) + ->and($result->dockerComposeFiles)->toBe([]) + ->and($result->envFiles)->toBe([]) + ->and($result->dockerfilePorts)->toBe([]); +}); + +test('getSuggestedBuildPack returns dockercompose when compose files found', function () { + $result = new RepositoryDetectionResult( + dockerfiles: ['Dockerfile'], + dockerComposeFiles: ['docker-compose.yml'], + ); + + expect($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERCOMPOSE); +}); + +test('getSuggestedBuildPack returns dockerfile when only dockerfiles found', function () { + $result = new RepositoryDetectionResult( + dockerfiles: ['Dockerfile'], + ); + + expect($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERFILE); +}); + +test('getSuggestedBuildPack returns null when nothing found', function () { + $result = RepositoryDetectionResult::none(); + + expect($result->getSuggestedBuildPack())->toBeNull(); +}); + +test('getSuggestedBuildPack ignores files in subdirectories', function () { + $result = new RepositoryDetectionResult( + dockerfiles: ['Dockerfile', 'apps/api/Dockerfile'], + dockerComposeFiles: ['examples/demo/docker-compose.yml'], + ); + + expect($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERFILE); + + $nestedOnly = new RepositoryDetectionResult( + dockerfiles: ['apps/api/Dockerfile'], + dockerComposeFiles: ['examples/demo/docker-compose.yml'], + ); + + expect($nestedOnly->getSuggestedBuildPack())->toBeNull(); +}); + +test('hasDockerfile returns true when dockerfiles present', function () { + $result = new RepositoryDetectionResult( + dockerfiles: ['Dockerfile', 'apps/api/Dockerfile'], + ); + + expect($result->hasDockerfile())->toBeTrue(); +}); + +test('hasDockerfile returns false when no dockerfiles', function () { + $result = RepositoryDetectionResult::none(); + + expect($result->hasDockerfile())->toBeFalse(); +}); + +test('hasDockerCompose returns true when compose files present', function () { + $result = new RepositoryDetectionResult( + dockerComposeFiles: ['docker-compose.yml'], + ); + + expect($result->hasDockerCompose())->toBeTrue(); +}); + +test('hasDockerCompose returns false when no compose files', function () { + $result = RepositoryDetectionResult::none(); + + expect($result->hasDockerCompose())->toBeFalse(); +}); + +test('dockerfilePorts stores port mapping correctly', function () { + $result = new RepositoryDetectionResult( + dockerfiles: ['Dockerfile', 'apps/api/Dockerfile'], + dockerfilePorts: ['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080], + ); + + expect($result->dockerfilePorts)->toBe(['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080]) + ->and($result->dockerfilePorts['Dockerfile'])->toBe(3000) + ->and($result->dockerfilePorts['apps/api/Dockerfile'])->toBe(8080); +}); + +test('hasEnvFiles returns true when env files present', function () { + $result = new RepositoryDetectionResult( + envFiles: ['.env.example' => 'APP_KEY=secret'], + ); + + expect($result->hasEnvFiles())->toBeTrue(); +}); + +test('hasEnvFiles returns false when no env files', function () { + $result = RepositoryDetectionResult::none(); + + expect($result->hasEnvFiles())->toBeFalse(); +}); + +test('envFiles stores multiple files with content', function () { + $result = new RepositoryDetectionResult( + envFiles: [ + '.env.example' => 'APP_KEY=secret', + '.env.sample' => 'DB_HOST=localhost', + '.env.dist' => null, + ], + ); + + expect($result->envFiles)->toHaveCount(3) + ->and($result->envFiles['.env.example'])->toBe('APP_KEY=secret') + ->and($result->envFiles['.env.sample'])->toBe('DB_HOST=localhost') + ->and($result->envFiles['.env.dist'])->toBeNull(); +}); diff --git a/tests/Unit/RepositoryDetectorTest.php b/tests/Unit/RepositoryDetectorTest.php new file mode 100644 index 0000000000..0281ed5391 --- /dev/null +++ b/tests/Unit/RepositoryDetectorTest.php @@ -0,0 +1,231 @@ +forceFill(['git_repository' => $repository, 'git_branch' => 'main', 'git_commit_sha' => 'HEAD']); + $application->setRelation('settings', (new ApplicationSetting)->forceFill(['is_git_shallow_clone_enabled' => true, 'is_git_submodules_enabled' => false])); + $application->setRelation('source', null); + $application->setRelation('private_key', null); + + return new RepositoryDetector($application, $baseDirectory, new Server); +} + +/** + * Runs the scan commands on this machine against a local Git repository, optionally after the + * non-root sudo parser (with `sudo` removed, so the test does not need root). + */ +function runRepositoryScan(RepositoryDetector $detector, string $uuid, bool $throughSudoParser = false): Process +{ + $commands = (new ReflectionMethod($detector, 'scanCommands'))->invoke($detector, $uuid); + if ($throughSudoParser) { + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu'); + $commands = collect(parseCommandsByLineForSudo($commands, $server)) + ->map(fn (string $line) => preg_replace('/(^|\s)sudo /', '$1', $line)) + ->map(fn (string $line) => preg_replace('/ && chown .*$/', '', $line)); + } + $process = Process::fromShellCommandline("set -e\n".$commands->implode("\n")); + $process->run(); + + return $process; +} + +beforeEach(function () { + $this->detector = repositoryDetector(); + $this->parseOutputMethod = new ReflectionMethod($this->detector, 'parseOutput'); +}); + +test('parseOutput handles complete detection output', function () { + $output = json_encode([ + 'dockerfiles' => ['Dockerfile', 'apps/api/Dockerfile'], + 'dockerComposeFiles' => ['docker-compose.yml'], + 'envFiles' => ['.env.example' => "APP_NAME=MyApp\nAPP_ENV=production\nDB_HOST=localhost\nDB_PORT=5432"], + 'dockerfilePorts' => ['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080], + ]); + + $result = $this->parseOutputMethod->invoke($this->detector, $output); + + expect($result->dockerfiles)->toBe(['Dockerfile', 'apps/api/Dockerfile']) + ->and($result->dockerComposeFiles)->toBe(['docker-compose.yml']) + ->and($result->envFiles)->toHaveKey('.env.example') + ->and($result->envFiles['.env.example'])->toContain('APP_NAME=MyApp') + ->and($result->dockerfilePorts)->toBe(['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080]) + ->and($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERCOMPOSE); +}); + +test('parseOutput handles empty repository', function () { + $output = json_encode([ + 'dockerfiles' => [], + 'dockerComposeFiles' => [], + 'envFiles' => (object) [], + 'dockerfilePorts' => (object) [], + ]); + + $result = $this->parseOutputMethod->invoke($this->detector, $output); + + expect($result->dockerfiles)->toBe([]) + ->and($result->dockerComposeFiles)->toBe([]) + ->and($result->envFiles)->toBe([]) + ->and($result->dockerfilePorts)->toBe([]) + ->and($result->getSuggestedBuildPack())->toBeNull(); +}); + +test('parseOutput handles dockerfile without EXPOSE', function () { + $output = json_encode([ + 'dockerfiles' => ['Dockerfile'], + 'dockerComposeFiles' => [], + 'envFiles' => (object) [], + 'dockerfilePorts' => ['Dockerfile' => null], + ]); + + $result = $this->parseOutputMethod->invoke($this->detector, $output); + + expect($result->dockerfiles)->toBe(['Dockerfile']) + ->and($result->dockerfilePorts)->toBe(['Dockerfile' => null]) + ->and($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERFILE); +}); + +test('parseOutput handles only env files without dockerfiles', function () { + $output = json_encode([ + 'dockerfiles' => [], + 'dockerComposeFiles' => [], + 'envFiles' => ['.env.example' => "SECRET_KEY=changeme\nDATABASE_URL=postgres://localhost/db"], + 'dockerfilePorts' => (object) [], + ]); + + $result = $this->parseOutputMethod->invoke($this->detector, $output); + + expect($result->dockerfiles)->toBe([]) + ->and($result->envFiles)->toHaveKey('.env.example') + ->and($result->envFiles['.env.example'])->toContain('SECRET_KEY=changeme') + ->and($result->envFiles['.env.example'])->toContain('DATABASE_URL=postgres://localhost/db') + ->and($result->getSuggestedBuildPack())->toBeNull(); +}); + +test('parseOutput handles multiple compose files', function () { + $output = json_encode([ + 'dockerfiles' => [], + 'dockerComposeFiles' => ['docker-compose.yml', 'compose.yaml'], + 'envFiles' => (object) [], + 'dockerfilePorts' => (object) [], + ]); + + $result = $this->parseOutputMethod->invoke($this->detector, $output); + + expect($result->dockerComposeFiles)->toBe(['docker-compose.yml', 'compose.yaml']) + ->and($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERCOMPOSE); +}); + +test('parseOutput puts the file nearest to the base directory first', function () { + $output = json_encode([ + 'dockerfiles' => ['apps/api/Dockerfile', 'Dockerfile.dev', 'apps/web/Dockerfile', 'Dockerfile'], + 'dockerComposeFiles' => ['examples/demo/compose.yaml', 'deploy/docker-compose.yml', 'compose.yaml'], + 'envFiles' => (object) [], + 'dockerfilePorts' => (object) [], + ]); + + $result = $this->parseOutputMethod->invoke($this->detector, $output); + + expect($result->dockerfiles)->toBe(['Dockerfile', 'Dockerfile.dev', 'apps/api/Dockerfile', 'apps/web/Dockerfile']) + ->and($result->dockerComposeFiles)->toBe(['compose.yaml', 'deploy/docker-compose.yml', 'examples/demo/compose.yaml']); +}); + +test('parseOutput handles multiple env files', function () { + $output = json_encode([ + 'dockerfiles' => [], + 'dockerComposeFiles' => [], + 'envFiles' => [ + '.env.example' => "APP_KEY=base64:abc\nAPP_ENV=local", + '.env.sample' => "DB_HOST=127.0.0.1\nDB_PORT=5432", + '.env.dist' => 'REDIS_HOST=localhost', + ], + 'dockerfilePorts' => (object) [], + ]); + + $result = $this->parseOutputMethod->invoke($this->detector, $output); + + expect($result->envFiles)->toHaveCount(3) + ->and($result->envFiles['.env.example'])->toContain('APP_KEY=base64:abc') + ->and($result->envFiles['.env.sample'])->toContain('DB_HOST=127.0.0.1') + ->and($result->envFiles['.env.dist'])->toContain('REDIS_HOST=localhost') + ->and($result->hasEnvFiles())->toBeTrue(); +}); + +test('parseOutput handles string port values from JSON', function () { + $output = json_encode([ + 'dockerfiles' => ['Dockerfile'], + 'dockerComposeFiles' => [], + 'envFiles' => (object) [], + 'dockerfilePorts' => ['Dockerfile' => '3000'], + ]); + + $result = $this->parseOutputMethod->invoke($this->detector, $output); + + expect($result->dockerfilePorts)->toBe(['Dockerfile' => 3000]) + ->and($result->dockerfilePorts['Dockerfile'])->toBeInt(); +}); + +test('parseOutput returns none for invalid JSON', function () { + $result = $this->parseOutputMethod->invoke($this->detector, 'not valid json'); + + expect($result->dockerfiles)->toBe([]) + ->and($result->dockerComposeFiles)->toBe([]) + ->and($result->envFiles)->toBe([]) + ->and($result->dockerfilePorts)->toBe([]); +}); + +test('scanner rejects traversal before remote execution', function (string $baseDirectory) { + $detector = repositoryDetector(baseDirectory: $baseDirectory); + expect(fn () => (new ReflectionMethod($detector, 'scanCommands'))->invoke($detector, 'scan-test'))->toThrow(RuntimeException::class); +})->with(['/../outside', '/app/../..', '/./app', '/app;id']); + +test('scanner reads only regular Git blobs and honors build directory', function (bool $throughSudoParser) { + $repo = sys_get_temp_dir().'/scan-fixture-'.bin2hex(random_bytes(8)); + $uuid = 'scan-test-'.bin2hex(random_bytes(8)); + mkdir($repo); + mkdir($repo.'/app'); + mkdir($repo.'/app/services'); + file_put_contents($repo.'/Dockerfile', "FROM scratch\nEXPOSE 9000\n"); + file_put_contents($repo.'/app/Dockerfile', "FROM scratch\r\n expose 8080/tcp\r\n"); + file_put_contents($repo.'/app/services/Dockerfile.worker', "FROM scratch\nEXPOSE \${PORT}\n"); + file_put_contents($repo.'/app/compose.yaml', 'services: {}'); + file_put_contents($repo.'/app/.env.sample', "SAFE=value\nQUOTE='it''s'"); + file_put_contents($repo.'/app/services/.env.example', 'NESTED=ignored'); + file_put_contents($repo.'/app/my dockerfile notes.txt', 'not a Dockerfile'); + file_put_contents($repo.'/secret', 'HOST_SECRET=do-not-read'); + symlink($repo.'/secret', $repo.'/app/.env.example'); + $git = 'git -C '.escapeshellarg($repo); + Process::fromShellCommandline("{$git} init -q -b main && {$git} add . && {$git} -c user.name=Test -c user.email=test@example.com commit -qm fixture")->mustRun(); + try { + $detector = repositoryDetector($repo, '/app'); + $process = runRepositoryScan($detector, $uuid, $throughSudoParser); + expect($process->isSuccessful())->toBeTrue($process->getErrorOutput()); + $result = (new ReflectionMethod($detector, 'parseOutput'))->invoke($detector, $process->getOutput()); + expect($result->dockerfiles)->toBe(['Dockerfile', 'services/Dockerfile.worker']) + ->and($result->dockerComposeFiles)->toBe(['compose.yaml']) + ->and($result->envFiles)->toBe(['.env.sample' => "SAFE=value\nQUOTE='it''s'"]) + ->and($result->dockerfilePorts)->toBe(['Dockerfile' => 8080, 'services/Dockerfile.worker' => null]); + + $failure = runRepositoryScan(repositoryDetector($repo.'-missing'), $uuid.'-missing', $throughSudoParser); + expect($failure->isSuccessful())->toBeFalse(); + } finally { + Process::fromShellCommandline('rm -rf -- '.escapeshellarg($repo).' /tmp/'.$uuid.' /tmp/'.$uuid.'-missing')->mustRun(); + } +})->with(['root server' => false, 'non-root server' => true]); + +test('invalid port values are not suggested', function (mixed $port) { + $result = $this->parseOutputMethod->invoke($this->detector, json_encode(['dockerfilePorts' => ['Dockerfile' => $port]])); + expect($result->dockerfilePorts['Dockerfile'])->toBeNull(); +})->with([0, -1, 65536, '3.5', '3e3', true]); + +test('malformed scan collections fall back safely', function () { + $result = $this->parseOutputMethod->invoke($this->detector, '{"dockerfiles":"Dockerfile"}'); + expect($result->dockerfiles)->toBe([]); +}); From d4d8bba514671e30e33d5b795e3ecc31ae6de4c8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:10:34 +0200 Subject: [PATCH 042/129] docs(backups): add multi-destination S3 planning notes (#11789) Co-authored-by: Claude Opus 5.5 --- .ai/lessons.md | 1 + .../StartDevelopmentInstanceDatabase.php | 33 ++ .../Shared/DeleteScheduledVolumeBackup.php | 22 +- .../SeedDevelopmentQemuServerCommand.php | 11 + .../Controllers/Api/DatabasesController.php | 189 +++++--- .../Controllers/Api/ServicesController.php | 6 +- .../Api/VolumeBackupsController.php | 52 ++- app/Jobs/DatabaseBackupJob.php | 67 ++- app/Jobs/VolumeBackupJob.php | 118 +++-- app/Jobs/VolumeBackupRecoveryJob.php | 37 +- .../Project/Application/Backup/Index.php | 2 +- app/Livewire/Project/CloneMe.php | 2 + app/Livewire/Project/Database/BackupEdit.php | 82 +++- .../Project/Database/BackupExecutions.php | 8 +- .../Project/Database/ScheduledBackups.php | 2 +- .../Project/Service/BackupExecutions.php | 17 +- .../Project/Service/VolumeBackup/Index.php | 4 +- .../Project/Shared/ResourceOperations.php | 1 + .../Project/Shared/Storages/VolumeBackups.php | 89 ++-- app/Livewire/Storage/Resources.php | 102 ++-- app/Livewire/Storage/Show.php | 6 +- app/Mcp/Tools/ListDatabaseBackups.php | 2 + app/Models/DatabaseBackupS3Replica.php | 38 ++ app/Models/S3Storage.php | 57 ++- app/Models/ScheduledDatabaseBackup.php | 11 +- .../ScheduledDatabaseBackupExecution.php | 9 + app/Models/ScheduledVolumeBackup.php | 9 + app/Models/ScheduledVolumeBackupExecution.php | 9 + app/Models/VolumeBackupS3Replica.php | 38 ++ .../ServerTransfer/ServerTransferExporter.php | 25 +- .../ServerTransfer/ServerTransferImporter.php | 36 +- app/Traits/HasS3Destinations.php | 68 +++ app/Traits/HasS3Replicas.php | 75 +++ bootstrap/helpers/databases.php | 248 +++++----- ...00_create_backup_s3_destination_tables.php | 126 +++++ openapi.json | 48 +- openapi.yaml | 32 +- .../views/components/status-badge.blade.php | 22 +- .../application/backup/index.blade.php | 7 +- .../project/database/backup-edit/s3.blade.php | 11 +- .../database/backup-executions.blade.php | 31 +- .../database/scheduled-backups.blade.php | 9 +- .../service/volume-backup/index.blade.php | 16 +- .../volume-backups/executions.blade.php | 27 +- .../storages/volume-backups/s3.blade.php | 12 +- .../livewire/storage/resources.blade.php | 14 +- .../Api/BackupS3DestinationsApiTest.php | 292 ++++++++++++ tests/Feature/BackupEditValidationTest.php | 6 +- .../BackupRetentionAndStaleDetectionTest.php | 2 + .../Feature/BackupS3DestinationsFormTest.php | 322 +++++++++++++ ...tabaseBackupMultipleS3DestinationsTest.php | 434 ++++++++++++++++++ tests/Feature/DevelopmentQemuVmTest.php | 25 + ...stanceBackupMultipleS3DestinationsTest.php | 137 ++++++ tests/Feature/Jobs/DatabaseBackupJobTest.php | 26 +- tests/Feature/Mcp/McpReadToolsTest.php | 31 ++ ...bleLivewireComponentsAuthorizationTest.php | 2 +- .../ResourceTeamAfterTeamSwitchTest.php | 2 +- tests/Feature/ScheduleAuditTest.php | 4 +- ...VolumeBackupMultipleS3DestinationsTest.php | 386 ++++++++++++++++ .../VolumeBackupRecoveryBoundsTest.php | 11 +- tests/Feature/VolumeBackupTest.php | 59 ++- .../ServerTransferS3DestinationsTest.php | 144 ++++++ 62 files changed, 3141 insertions(+), 573 deletions(-) create mode 100644 app/Actions/Development/StartDevelopmentInstanceDatabase.php create mode 100644 app/Models/DatabaseBackupS3Replica.php create mode 100644 app/Models/VolumeBackupS3Replica.php create mode 100644 app/Traits/HasS3Destinations.php create mode 100644 app/Traits/HasS3Replicas.php create mode 100644 database/migrations/2026_10_07_150000_create_backup_s3_destination_tables.php create mode 100644 tests/Feature/Api/BackupS3DestinationsApiTest.php create mode 100644 tests/Feature/BackupS3DestinationsFormTest.php create mode 100644 tests/Feature/DatabaseBackupMultipleS3DestinationsTest.php create mode 100644 tests/Feature/InstanceBackupMultipleS3DestinationsTest.php create mode 100644 tests/Feature/VolumeBackupMultipleS3DestinationsTest.php create mode 100644 tests/Unit/ServerTransfer/ServerTransferS3DestinationsTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index 5cde3ef34e..7b94e29b3b 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -12,6 +12,7 @@ - SSH retries replay the full command batch. Only exit code 255 is an SSH transport failure; other exit codes come from the remote command, so do not retry them. Keep batches safe to run twice (for example `docker rm -f || true` before `docker run --name `). - Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open. - Browser `click('Text')` can match a hidden element first (for example inactive modal steps) and wait forever; use `button:visible:has-text("Text")`. +- A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error. - Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only. - Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`. diff --git a/app/Actions/Development/StartDevelopmentInstanceDatabase.php b/app/Actions/Development/StartDevelopmentInstanceDatabase.php new file mode 100644 index 0000000000..41b8428bd4 --- /dev/null +++ b/app/Actions/Development/StartDevelopmentInstanceDatabase.php @@ -0,0 +1,33 @@ + config('database.connections.pgsql.username'), + 'POSTGRES_PASSWORD' => config('database.connections.pgsql.password'), + 'POSTGRES_DB' => config('database.connections.pgsql.database'), + ])->map(fn ($value, string $key): string => '-e '.escapeshellarg("{$key}={$value}"))->implode(' '); + + instant_remote_process([ + 'if ! docker inspect coolify-db >/dev/null 2>&1; then', + "docker run -d --name coolify-db --restart unless-stopped {$environment} postgres:15-alpine", + 'fi', + 'docker start coolify-db', + ], $server, disableMultiplexing: true); + } +} diff --git a/app/Actions/Shared/DeleteScheduledVolumeBackup.php b/app/Actions/Shared/DeleteScheduledVolumeBackup.php index d56cce9384..afeb44fce6 100644 --- a/app/Actions/Shared/DeleteScheduledVolumeBackup.php +++ b/app/Actions/Shared/DeleteScheduledVolumeBackup.php @@ -4,6 +4,7 @@ namespace App\Actions\Shared; use App\Jobs\VolumeBackupJob; use App\Models\ScheduledVolumeBackup; +use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; use Illuminate\Support\Facades\Cache; use Lorisleiva\Actions\Concerns\AsAction; @@ -52,23 +53,10 @@ class DeleteScheduledVolumeBackup } if ($deleteS3Archives) { - $s3Executions = $backup->executions() - ->with('s3') - ->where('s3_uploaded', true) - ->where('s3_storage_deleted', false) - ->get(); - - foreach ($s3Executions->groupBy('s3_storage_id') as $executions) { - $s3 = $executions->first()->s3; - if (! $s3) { - throw new \RuntimeException('The S3 storage used by an existing backup is unavailable.'); - } - - $filenames = $executions->pluck('filename')->filter()->all(); - if ($filenames !== []) { - deleteBackupsS3($filenames, $s3); - } - } + $backup->executions() + ->whereHas('s3Replicas', fn ($query) => $query->where('s3_uploaded', true)->where('s3_storage_deleted', false)) + ->get() + ->each(fn (ScheduledVolumeBackupExecution $execution) => $execution->deleteS3Copies()); } $backup->delete(); diff --git a/app/Console/Commands/SeedDevelopmentQemuServerCommand.php b/app/Console/Commands/SeedDevelopmentQemuServerCommand.php index 082ad83ae2..26fc463ff7 100644 --- a/app/Console/Commands/SeedDevelopmentQemuServerCommand.php +++ b/app/Console/Commands/SeedDevelopmentQemuServerCommand.php @@ -3,7 +3,9 @@ namespace App\Console\Commands; use App\Actions\Development\SeedDevelopmentQemuServer; +use App\Actions\Development\StartDevelopmentInstanceDatabase; use Illuminate\Console\Command; +use Throwable; class SeedDevelopmentQemuServerCommand extends Command { @@ -22,6 +24,15 @@ class SeedDevelopmentQemuServerCommand extends Command $server = SeedDevelopmentQemuServer::run($this->argument('profile'), ! $this->option('keep-others'), (bool) $this->option('as-localhost'), $this->option('ip') ?: null, (int) $this->option('port')); $this->info("Seeded {$server->name} at {$server->ip}."); + if ($this->option('as-localhost')) { + try { + StartDevelopmentInstanceDatabase::run($server); + $this->info('Started the coolify-db container for instance backups.'); + } catch (Throwable $e) { + $this->warn("Could not start the coolify-db container for instance backups: {$e->getMessage()}"); + } + } + return self::SUCCESS; } } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index d71b64563b..b6fe02d8bc 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -32,6 +32,8 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; @@ -152,6 +154,58 @@ class DatabasesController extends Controller } } + /** + * Resolves the S3 destinations of a backup request, primary first. `s3_storage_uuids` lists every destination; + * `s3_storage_uuid` alone selects a single destination, and sent with `s3_storage_uuids` it must be in the list and + * becomes the primary destination. + * + * @return Collection|JsonResponse|null Null when the request does not select destinations. + */ + private function requestedBackupS3Storages(Request $request, int|string $teamId): Collection|JsonResponse|null + { + $hasList = $request->has('s3_storage_uuids'); + if (! $hasList && ! $request->filled('s3_storage_uuid')) { + return null; + } + + $uuids = collect($hasList ? $request->input('s3_storage_uuids') : [$request->input('s3_storage_uuid')])->unique()->values(); + $primaryUuid = $request->input('s3_storage_uuid') ?? $uuids->first(); + $errorField = $hasList ? 's3_storage_uuids' : 's3_storage_uuid'; + + if (filled($primaryUuid) && ! $uuids->contains($primaryUuid)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid must be one of the s3_storage_uuids.']], + ], 422); + } + + $storages = S3Storage::ownedByCurrentTeamAPI($teamId)->whereIn('uuid', $uuids->all())->get(); + if ($storages->count() !== $uuids->count()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [$errorField => ['The selected S3 storage is invalid for this team.']], + ], 422); + } + + return $storages->sortBy(fn (S3Storage $storage): int => $storage->uuid === $primaryUuid ? 0 : 1)->values(); + } + + /** + * Adds the S3 destination UUIDs (primary first) to a serialized backup without exposing the storages themselves. + */ + private function withBackupS3StorageUuids(ScheduledDatabaseBackup $backup): ScheduledDatabaseBackup + { + $storages = $backup->s3Storages->sortBy(fn (S3Storage $storage): int => $storage->id === $backup->s3_storage_id ? 0 : 1); + if ($storages->isEmpty() && $backup->s3) { + $storages = collect([$backup->s3]); + } + + $backup->setAttribute('s3_storage_uuid', $backup->s3?->uuid); + $backup->setAttribute('s3_storage_uuids', $storages->pluck('uuid')->values()->all()); + + return $backup->unsetRelation('s3Storages')->unsetRelation('s3'); + } + private function removeSensitiveData($database, bool $loadNestedServerSecrets = false) { $database->makeHidden([ @@ -302,9 +356,10 @@ class DatabasesController extends Controller $databaseIds = $databases->pluck('id')->toArray(); - $backupConfigs = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('latest_log') + $backupConfigs = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with(['latest_log', 's3', 's3Storages']) ->whereIn('database_id', $databaseIds) ->get() + ->map(fn (ScheduledDatabaseBackup $backup) => $this->withBackupS3StorageUuids($backup)) ->groupBy(fn (ScheduledDatabaseBackup $backup) => $backup->database_type.':'.$backup->database_id); $databases = $databases->map(function ($database) use ($backupConfigs) { @@ -340,7 +395,7 @@ class DatabasesController extends Controller responses: [ new OA\Response( response: 200, - description: 'Get all backups for a database', + description: 'Get all backups for a database. Each backup includes s3_storage_uuid (primary S3 destination) and s3_storage_uuids (every S3 destination, primary first).', content: new OA\JsonContent( type: 'string', example: 'Content is very complex. Will be implemented later.', @@ -376,7 +431,8 @@ class DatabasesController extends Controller $this->authorize('view', $database); - $backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('executions')->where('database_id', $database->id)->get(); + $backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with(['executions', 's3', 's3Storages'])->where('database_id', $database->id)->get() + ->map(fn (ScheduledDatabaseBackup $backup) => $this->withBackupS3StorageUuids($backup)); return response()->json($backupConfig); } @@ -873,7 +929,8 @@ class DatabasesController extends Controller 'frequency' => ['type' => 'string', 'description' => 'Backup frequency (cron expression or: every_minute, hourly, daily, weekly, monthly, yearly)'], 'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled', 'default' => true], 'save_s3' => ['type' => 'boolean', 'description' => 'Whether to save backups to S3', 'default' => false], - 's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID (required if save_s3 is true)'], + 's3_storage_uuid' => ['type' => 'string', 'description' => 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination. Either field is required if save_s3 is true.'], + 's3_storage_uuids' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination.'], 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], 'dump_all' => ['type' => 'boolean', 'description' => 'Whether to dump all databases', 'default' => false], 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'], @@ -921,7 +978,7 @@ class DatabasesController extends Controller )] public function create_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 's3_storage_uuids', 'timeout', 'missing_backup_notification_days']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -941,6 +998,8 @@ class DatabasesController extends Controller 'dump_all' => 'boolean', 'backup_now' => 'boolean|nullable', 's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable', + 's3_storage_uuids' => 'array', + 's3_storage_uuids.*' => 'string|distinct', 'databases_to_backup' => 'string|nullable', 'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0', @@ -986,24 +1045,17 @@ class DatabasesController extends Controller ], 422); } - // Validate S3 storage if save_s3 is true - if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) { + $s3Storages = $this->requestedBackupS3Storages($request, $teamId); + if ($s3Storages instanceof JsonResponse) { + return $s3Storages; + } + if ($request->boolean('save_s3') && blank($s3Storages)) { return response()->json([ 'message' => 'Validation failed.', - 'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']], + 'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid or s3_storage_uuids field is required when save_s3 is true.']], ], 422); } - if ($request->filled('s3_storage_uuid')) { - $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists(); - if (! $existsInTeam) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], - ], 422); - } - } - // Check for extra fields $extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']); if (! empty($extraFields)) { @@ -1018,20 +1070,9 @@ class DatabasesController extends Controller ], 422); } - $backupData = $request->only($backupConfigFields); - - // Convert s3_storage_uuid to s3_storage_id - if (isset($backupData['s3_storage_uuid'])) { - $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first(); - if ($s3Storage) { - $backupData['s3_storage_id'] = $s3Storage->id; - } elseif ($request->boolean('save_s3')) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], - ], 422); - } - unset($backupData['s3_storage_uuid']); + $backupData = Arr::except($request->only($backupConfigFields), ['s3_storage_uuid', 's3_storage_uuids']); + if ($s3Storages !== null) { + $backupData['s3_storage_id'] = $s3Storages->first()?->id; } // Set default databases_to_backup based on database type if not provided @@ -1070,6 +1111,9 @@ class DatabasesController extends Controller } $backupConfig = ScheduledDatabaseBackup::create($backupData); + if ($s3Storages !== null) { + $backupConfig->syncS3Storages($s3Storages->pluck('id')->all()); + } // Trigger immediate backup if requested if ($request->backup_now) { @@ -1129,7 +1173,8 @@ class DatabasesController extends Controller type: 'object', properties: [ 'save_s3' => ['type' => 'boolean', 'description' => 'Whether data is saved in s3 or not'], - 's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID'], + 's3_storage_uuid' => ['type' => 'string', 'description' => 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination; when it is already a destination, it becomes the primary destination and the other destinations stay.'], + 's3_storage_uuids' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination.'], 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not'], 'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled or not'], 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], @@ -1172,7 +1217,7 @@ class DatabasesController extends Controller )] public function update_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 's3_storage_uuids', 'timeout', 'missing_backup_notification_days']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -1189,6 +1234,8 @@ class DatabasesController extends Controller 'enabled' => 'boolean', 'dump_all' => 'boolean', 's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable', + 's3_storage_uuids' => 'array', + 's3_storage_uuids.*' => 'string|distinct', 'databases_to_backup' => 'string|nullable', 'frequency' => 'string', 'database_backup_retention_amount_locally' => 'integer|min:0', @@ -1236,20 +1283,9 @@ class DatabasesController extends Controller } } - if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']], - ], 422); - } - if ($request->filled('s3_storage_uuid')) { - $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists(); - if (! $existsInTeam) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], - ], 422); - } + $s3Storages = $this->requestedBackupS3Storages($request, $teamId); + if ($s3Storages instanceof JsonResponse) { + return $s3Storages; } $backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id) @@ -1259,6 +1295,14 @@ class DatabasesController extends Controller return response()->json(['message' => 'Backup config not found.'], 404); } + $saveToS3 = $request->has('save_s3') ? $request->boolean('save_s3') : (bool) $backupConfig->save_s3; + if (($request->boolean('save_s3') && $s3Storages === null) || ($saveToS3 && $s3Storages?->isEmpty())) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid or s3_storage_uuids field is required when save_s3 is true.']], + ], 422); + } + $extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']); if (! empty($extraFields)) { $errors = $validator->errors(); @@ -1272,20 +1316,9 @@ class DatabasesController extends Controller ], 422); } - $backupData = $request->only($backupConfigFields); - - // Convert s3_storage_uuid to s3_storage_id - if (isset($backupData['s3_storage_uuid'])) { - $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first(); - if ($s3Storage) { - $backupData['s3_storage_id'] = $s3Storage->id; - } elseif ($request->boolean('save_s3')) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], - ], 422); - } - unset($backupData['s3_storage_uuid']); + $backupData = Arr::except($request->only($backupConfigFields), ['s3_storage_uuid', 's3_storage_uuids']); + if ($s3Storages !== null) { + $backupData['s3_storage_id'] = $s3Storages->first()?->id; } // Validate databases_to_backup input @@ -1300,7 +1333,19 @@ class DatabasesController extends Controller } } + $storageIds = $s3Storages?->pluck('id'); + // An older client sends only s3_storage_uuid. When it is already a destination, the other destinations stay. + if ($storageIds !== null && ! $request->has('s3_storage_uuids')) { + $currentStorageIds = $backupConfig->selectedS3Storages()->pluck('id'); + if ($currentStorageIds->contains($storageIds->first())) { + $storageIds = $currentStorageIds; + } + } + $backupConfig->update($backupData); + if ($storageIds !== null) { + $backupConfig->syncS3Storages($storageIds->all()); + } if ($request->backup_now) { dispatch(new DatabaseBackupJob($backupConfig)); @@ -2938,9 +2983,10 @@ class DatabasesController extends Controller if ($execution->filename) { deleteBackupsLocally($execution->filename, $database->destination->server); - if ($deleteS3 && $backup->s3) { - deleteBackupsS3($execution->filename, $backup->s3); - } + } + + if ($deleteS3) { + $execution->deleteS3Copies(); } $execution->delete(); @@ -3073,9 +3119,10 @@ class DatabasesController extends Controller if ($execution->filename) { deleteBackupsLocally($execution->filename, $database->destination->server); - if ($deleteS3 && $backup->s3) { - deleteBackupsS3($execution->filename, $backup->s3); - } + } + + if ($deleteS3) { + $execution->deleteS3Copies(); } $execution->delete(); @@ -5195,7 +5242,7 @@ class DatabasesController extends Controller } foreach ($database->scheduledBackups()->get() as $backup) { - $backup->replicate([ + $newBackup = $backup->replicate([ 'id', 'created_at', 'updated_at', @@ -5205,7 +5252,9 @@ class DatabasesController extends Controller 'database_id' => $newDatabase->id, 'database_type' => $newDatabase->getMorphClass(), 'team_id' => $teamId, - ])->save(); + ]); + $newBackup->save(); + $backup->copyS3StoragesTo($newBackup); } foreach ($database->environment_variables()->get() as $environmentVariable) { diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 7411f8a472..01f3657434 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -3399,7 +3399,7 @@ class ServicesController extends Controller } foreach ($sourceDatabase->scheduledBackups()->get() as $backup) { - $backup->replicate([ + $newBackup = $backup->replicate([ 'id', 'created_at', 'updated_at', @@ -3408,7 +3408,9 @@ class ServicesController extends Controller 'database_id' => $database->id, 'database_type' => $database->getMorphClass(), 'team_id' => $teamId, - ])->save(); + ]); + $newBackup->save(); + $backup->copyS3StoragesTo($newBackup); } } diff --git a/app/Http/Controllers/Api/VolumeBackupsController.php b/app/Http/Controllers/Api/VolumeBackupsController.php index e3b48a367a..a199f530db 100644 --- a/app/Http/Controllers/Api/VolumeBackupsController.php +++ b/app/Http/Controllers/Api/VolumeBackupsController.php @@ -14,6 +14,7 @@ use App\Models\Service; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\MessageBag; use OpenApi\Attributes as OA; use RuntimeException; @@ -27,7 +28,8 @@ use RuntimeException; new OA\Property(property: 'save_s3', type: 'boolean', default: false), new OA\Property(property: 'disable_local_backup', type: 'boolean', default: false), new OA\Property(property: 'stop_during_backup', type: 'boolean', default: false), - new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true), + new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true, description: 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination.'), + new OA\Property(property: 's3_storage_uuids', type: 'array', items: new OA\Items(type: 'string'), description: 'UUIDs of every S3 storage the backup is uploaded to. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination. save_s3 requires at least one destination.'), new OA\Property(property: 'retention_amount_locally', type: 'integer', default: 7, minimum: 0, maximum: 10000), new OA\Property(property: 'retention_days_locally', type: 'integer', default: 0, maximum: 2147483647, minimum: 0), new OA\Property(property: 'retention_max_storage_locally', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0), @@ -53,7 +55,8 @@ use RuntimeException; new OA\Property(property: 'save_s3', type: 'boolean'), new OA\Property(property: 'disable_local_backup', type: 'boolean'), new OA\Property(property: 'stop_during_backup', type: 'boolean'), - new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true), + new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true, description: 'Primary S3 destination.'), + new OA\Property(property: 's3_storage_uuids', type: 'array', items: new OA\Items(type: 'string'), description: 'Every S3 destination, primary first.'), new OA\Property(property: 'retention_amount_locally', type: 'integer'), new OA\Property(property: 'retention_days_locally', type: 'integer'), new OA\Property(property: 'retention_max_storage_locally', type: 'number', format: 'float'), @@ -165,7 +168,7 @@ class VolumeBackupsController extends Controller return response()->json(['message' => 'Storage not found.'], 404); } - ['errors' => $errors, 's3Storage' => $s3Storage, 'saveToS3' => $saveToS3] = $this->validateUpsertRequest($request, $storage, $teamId); + ['errors' => $errors, 's3Storages' => $s3Storages, 'saveToS3' => $saveToS3] = $this->validateUpsertRequest($request, $storage, $teamId); if ($errors->isNotEmpty()) { return response()->json([ @@ -174,11 +177,11 @@ class VolumeBackupsController extends Controller ], 422); } - return $this->persistSchedule($request, $storage, $teamId, $s3Storage, $saveToS3, $resourceType, $resource); + return $this->persistSchedule($request, $storage, $teamId, $s3Storages, $saveToS3, $resourceType, $resource); } /** - * @return array{errors: MessageBag, s3Storage: S3Storage|null, saveToS3: bool} + * @return array{errors: MessageBag, s3Storages: Collection, saveToS3: bool} */ private function validateUpsertRequest( Request $request, @@ -192,6 +195,8 @@ class VolumeBackupsController extends Controller 'disable_local_backup' => 'boolean', 'stop_during_backup' => 'boolean', 's3_storage_uuid' => 'nullable|string', + 's3_storage_uuids' => 'array', + 's3_storage_uuids.*' => 'string|distinct', 'retention_amount_locally' => 'integer|min:0|max:10000', 'retention_days_locally' => 'integer|min:0|max:2147483647', 'retention_max_storage_locally' => 'numeric|min:0|max:9999999999', @@ -209,6 +214,7 @@ class VolumeBackupsController extends Controller 'disable_local_backup', 'stop_during_backup', 's3_storage_uuid', + 's3_storage_uuids', 'retention_amount_locally', 'retention_days_locally', 'retention_max_storage_locally', @@ -232,16 +238,24 @@ class VolumeBackupsController extends Controller $errors->add('disable_local_backup', 'Local backups can only be disabled when S3 backups are enabled.'); } - $s3Storage = null; - if ($saveToS3) { - $s3Storage = S3Storage::query() + $s3Storages = collect(); + if ($saveToS3 && ! $errors->hasAny(['s3_storage_uuid', 's3_storage_uuids', 's3_storage_uuids.*'])) { + $hasList = $request->has('s3_storage_uuids'); + $uuids = collect($hasList ? $request->input('s3_storage_uuids') : [$request->input('s3_storage_uuid')])->filter()->unique()->values(); + $primaryUuid = $request->input('s3_storage_uuid') ?? $uuids->first(); + + $s3Storages = S3Storage::query() ->where('team_id', $teamId) ->where('is_usable', true) - ->where('uuid', $request->input('s3_storage_uuid')) - ->first(); + ->whereIn('uuid', $uuids->all()) + ->get() + ->sortBy(fn (S3Storage $s3Storage): int => $s3Storage->uuid === $primaryUuid ? 0 : 1) + ->values(); - if (! $s3Storage) { - $errors->add('s3_storage_uuid', 'Select a usable S3 storage owned by your team.'); + if ($hasList && filled($primaryUuid) && ! $uuids->contains($primaryUuid)) { + $errors->add('s3_storage_uuid', 'The s3_storage_uuid must be one of the s3_storage_uuids.'); + } elseif ($uuids->isEmpty() || $s3Storages->count() !== $uuids->count()) { + $errors->add($hasList ? 's3_storage_uuids' : 's3_storage_uuid', 'Select a usable S3 storage owned by your team.'); } } @@ -251,7 +265,7 @@ class VolumeBackupsController extends Controller return [ 'errors' => $errors, - 's3Storage' => $s3Storage, + 's3Storages' => $s3Storages, 'saveToS3' => $saveToS3, ]; } @@ -260,7 +274,7 @@ class VolumeBackupsController extends Controller Request $request, LocalPersistentVolume|LocalFileVolume $storage, int|string $teamId, - ?S3Storage $s3Storage, + Collection $s3Storages, bool $saveToS3, string $resourceType, Model $resource, @@ -272,7 +286,7 @@ class VolumeBackupsController extends Controller 'save_s3' => $saveToS3, 'disable_local_backup' => $saveToS3 && $request->boolean('disable_local_backup'), 'stop_during_backup' => $request->boolean('stop_during_backup'), - 's3_storage_id' => $s3Storage?->id, + 's3_storage_id' => $s3Storages->first()?->id, 'retention_amount_locally' => $request->integer('retention_amount_locally', 7), 'retention_days_locally' => $request->integer('retention_days_locally'), 'retention_max_storage_locally' => $request->float('retention_max_storage_locally'), @@ -288,6 +302,7 @@ class VolumeBackupsController extends Controller } $backup = $storage->scheduledBackups()->updateOrCreate([], $attributes); + $backup->syncS3Storages($s3Storages->pluck('id')->all()); $created = $backup->wasRecentlyCreated; auditLog('api.volume_backup.schedule_set', [ @@ -298,7 +313,7 @@ class VolumeBackupsController extends Controller 'backup_uuid' => $backup->uuid, ]); - return response()->json($this->responseData($backup, $storage, $s3Storage, $created), $created ? 201 : 200); + return response()->json($this->responseData($backup, $storage, $s3Storages, $created), $created ? 201 : 200); } #[OA\Delete( @@ -431,7 +446,7 @@ class VolumeBackupsController extends Controller private function responseData( ScheduledVolumeBackup $backup, LocalPersistentVolume|LocalFileVolume $storage, - ?S3Storage $s3Storage, + Collection $s3Storages, bool $created, ): array { return [ @@ -444,7 +459,8 @@ class VolumeBackupsController extends Controller 'save_s3' => $backup->save_s3, 'disable_local_backup' => $backup->disable_local_backup, 'stop_during_backup' => $backup->stop_during_backup, - 's3_storage_uuid' => $s3Storage?->uuid, + 's3_storage_uuid' => $s3Storages->first()?->uuid, + 's3_storage_uuids' => $s3Storages->pluck('uuid')->all(), 'retention_amount_locally' => $backup->retention_amount_locally, 'retention_days_locally' => $backup->retention_days_locally, 'retention_max_storage_locally' => $backup->retention_max_storage_locally, diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 92d24bab3d..eb0959865b 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -83,8 +83,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue public ?string $mongo_root_password = null; - public ?S3Storage $s3 = null; - public $timeout = 3600 + self::WORKER_TIMEOUT_MARGIN_SECONDS; public ?string $backup_log_uuid = null; @@ -129,11 +127,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue if (data_get($this->backup, 'database_type') === ServiceDatabase::class) { $this->database = data_get($this->backup, 'database'); $this->server = $this->database->service->server; - $this->s3 = $this->backup->s3; } else { $this->database = data_get($this->backup, 'database'); $this->server = $this->database->destination->server; - $this->s3 = $this->backup->s3; } if (is_null($this->server)) { throw new \Exception('Server not found?!'); @@ -329,6 +325,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->backup_location = null; $this->backup_output = null; $this->error_output = null; + $this->s3_uploaded = false; // Generate unique UUID for each database backup execution $attempts = 0; @@ -484,10 +481,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $localStorageDeleted = false; if ($this->backup->save_s3 && $localBackupSucceeded) { try { - $this->upload_to_s3(); + $s3UploadError = $this->uploadToS3Destinations(); - // If local backup is disabled, delete the local file immediately after S3 upload - if ($this->backup->disable_local_backup) { + // If local backup is disabled, delete the local file only when every destination has a copy + if ($s3UploadError === null && $this->backup->disable_local_backup) { deleteBackupsLocally($this->backup_location, $this->server); $localStorageDeleted = true; } @@ -843,9 +840,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue return devHostDockerPath($this->server, $this->backup_location); } - private function upload_to_s3(): void + /** + * Uploads the backup file to every selected S3 destination and records one replica per destination. + * + * @return string|null The failed destinations and their errors, or null when every upload succeeded. + */ + private function uploadToS3Destinations(): ?string { - if (is_null($this->s3)) { + $storages = $this->backup->selectedS3Storages(); + if ($storages->isEmpty()) { $previousS3StorageId = $this->backup->s3_storage_id; $this->backup->update([ @@ -856,13 +859,43 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($previousS3StorageId ?? 'null').'). S3 backup has been disabled for this schedule.'); } + $failures = []; + foreach ($storages as $storage) { + $uploaded = false; + $uploadError = null; + try { + $this->upload_to_s3($storage); + $uploaded = true; + } catch (Throwable $e) { + $uploadError = $e->getMessage(); + $failures[] = "{$storage->name}: {$uploadError}"; + } + + try { + $this->backup_log->s3Replicas()->create([ + 's3_storage_id' => $storage->id, + 's3_uploaded' => $uploaded, + 'message' => $uploadError, + ]); + } catch (Throwable $e) { + $failures[] = "{$storage->name}: {$e->getMessage()}"; + } + } + + $this->backup_log->refreshS3Summary(); + $this->s3_uploaded = $failures === []; + + return $failures === [] ? null : implode("\n", $failures); + } + + protected function upload_to_s3(S3Storage $s3): void + { try { - $key = $this->s3->key; - $secret = $this->s3->secret; - // $region = $this->s3->region; - $bucket = $this->s3->bucket; - $endpoint = $this->s3->endpoint; - $this->s3->testConnection(shouldSave: true); + $key = $s3->key; + $secret = $s3->secret; + $bucket = $s3->bucket; + $endpoint = $s3->endpoint; + $s3->testConnection(shouldSave: true); if (data_get($this->backup, 'database_type') === ServiceDatabase::class) { $network = $this->database->service->destination->network; } else { @@ -883,7 +916,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $escapedSecret = escapeshellarg($secret); $escapedBackupLocation = escapeshellarg($this->backup_location); $escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/"); - $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint, $this->s3->trustedInternalHosts())) + $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint, $s3->trustedInternalHosts())) ->map(fn (string $resolveOption): string => '--resolve '.escapeshellarg($resolveOption)) ->implode(' '); $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; @@ -892,9 +925,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}"; instant_remote_process($commands, $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); - $this->s3_uploaded = true; } catch (Throwable $e) { - $this->s3_uploaded = false; $this->add_to_error_output($e->getMessage()); throw $e; } finally { diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index d635894b09..db226e5675 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -4,9 +4,11 @@ namespace App\Jobs; use App\Events\BackupCreated; use App\Models\LocalPersistentVolume; +use App\Models\S3Storage; use App\Models\ScheduledVolumeBackup; use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; +use App\Models\VolumeBackupS3Replica; use App\Notifications\VolumeBackup\BackupFailed; use App\Notifications\VolumeBackup\BackupSuccess; use App\Rules\SafeWebhookUrl; @@ -16,7 +18,6 @@ use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; @@ -71,16 +72,21 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue throw new \RuntimeException('The storage backup resource, team, or server no longer exists.'); } + $destinations = $this->backup->save_s3 ? $this->backup->selectedS3Storages() : collect(); $this->execution = $this->backup->executions()->create([ - 's3_storage_id' => $this->backup->save_s3 ? $this->backup->s3_storage_id : null, + 's3_storage_id' => $destinations->first()?->id, ]); + foreach ($destinations as $s3) { + $this->execution->s3Replicas()->create(['s3_storage_id' => $s3->id]); + } BackupCreated::dispatch($team->id); $backupDirectory = backup_dir().'/volumes/'.str($team->name)->slug().'-'.$team->id.'/'.$target->uuid; $filename = str($this->backup->targetType())->lower().'-'.str($this->backup->targetName())->slug().'-'.Carbon::now()->timestamp.'.tar.gz'; $backupLocation = $backupDirectory.'/'.$filename; $this->execution->update(['filename' => $backupLocation]); - $streamToS3 = $this->backup->save_s3 && $this->backup->disable_local_backup; + // Streaming writes no local archive, so it can upload to one destination only. + $streamToS3 = $this->backup->disable_local_backup && $destinations->count() === 1; try { $source = $this->backup->sourcePath(); @@ -96,7 +102,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $archiveScript = "compressor=\$({$compressorCommand}); tar -I \"\$compressor\" -cf - -C /volume ."; if ($streamToS3) { $this->execution->update(['local_storage_deleted' => true]); - $archiveCommand = $this->streamToS3Command($archiveScript, $backupLocation, $source, $containerName, $image); + $archiveCommand = $this->streamToS3Command($destinations->first(), $archiveScript, $backupLocation, $source, $containerName, $image); $this->execution->update(['s3_cleanup_pending' => true]); } else { $archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName) @@ -141,24 +147,27 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } $warning = null; - $s3Uploaded = null; + $s3Uploaded = false; $s3CleanupPending = false; $localStorageDeleted = $streamToS3; if ($streamToS3) { $s3Uploaded = true; + $this->execution->s3Replicas()->update(['s3_uploaded' => true]); + $this->execution->refreshS3Summary(); $this->execution->update(['s3_cleanup_pending' => false]); + } elseif ($this->backup->save_s3 && $destinations->isEmpty()) { + $warning = 'S3 upload failed: The selected S3 storage no longer exists.'; } elseif ($this->backup->save_s3) { $s3CleanupPending = true; $this->execution->update(['s3_cleanup_pending' => true]); + $uploadErrors = $this->uploadToDestinations($backupLocation, $backupDirectory, $server); + $s3Uploaded = $uploadErrors === []; - try { - $this->uploadToS3($backupLocation, $backupDirectory, $server); - $s3Uploaded = true; + if ($s3Uploaded) { $s3CleanupPending = false; - } catch (Throwable $exception) { - $s3Uploaded = false; - $warning = 'S3 upload failed: '.$exception->getMessage(); + } else { + $warning = 'S3 upload failed: '.implode(' ', $uploadErrors); try { VolumeBackupRecoveryJob::cleanupS3Upload($this->execution); @@ -184,7 +193,6 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue 'message' => $warning, 'size' => $size, 'filename' => $backupLocation, - 's3_uploaded' => $s3Uploaded, 's3_cleanup_pending' => $s3CleanupPending, 'local_storage_deleted' => $localStorageDeleted, ]); @@ -220,6 +228,8 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } + $this->execution->s3Replicas()->whereNull('s3_uploaded')->update(['s3_uploaded' => false]); + $this->execution->refreshS3Summary(); $s3CleanupPending = $this->execution->fresh()->s3_cleanup_pending; $this->execution->update([ @@ -347,16 +357,37 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } - private function uploadToS3(string $backupLocation, string $backupDirectory, Server $server): void + /** + * Uploads the local archive to every destination of the execution and records the result on each copy. + * + * @return array One error message per failed destination. + */ + private function uploadToDestinations(string $backupLocation, string $backupDirectory, Server $server): array { - $s3 = $this->backup->s3; + $errors = []; - if (! $s3) { - $this->backup->update(['save_s3' => false, 's3_storage_id' => null]); + foreach ($this->execution->s3Replicas()->with('s3')->get() as $replica) { + /** @var VolumeBackupS3Replica $replica */ + try { + if (! $replica->s3) { + throw new \RuntimeException('The selected S3 storage no longer exists.'); + } - throw new \RuntimeException('The selected S3 storage no longer exists. S3 backup has been disabled.'); + $this->uploadToS3($replica->s3, $backupLocation, $backupDirectory, $server); + $replica->update(['s3_uploaded' => true, 'message' => null]); + } catch (Throwable $exception) { + $replica->update(['s3_uploaded' => false, 'message' => $exception->getMessage()]); + $errors[] = ($replica->s3?->name ?? 'Deleted storage').': '.$exception->getMessage(); + } } + $this->execution->refreshS3Summary(); + + return $errors; + } + + private function uploadToS3(S3Storage $s3, string $backupLocation, string $backupDirectory, Server $server): void + { $s3->testConnection(shouldSave: true); $containerName = 'volume-upload-'.$this->execution->uuid; $image = coolifyHelperImage().':'.getHelperVersion(); @@ -385,16 +416,8 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } - private function streamToS3Command(string $archiveScript, string $backupLocation, string $source, string $containerName, string $image): string + private function streamToS3Command(S3Storage $s3, string $archiveScript, string $backupLocation, string $source, string $containerName, string $image): string { - $s3 = $this->backup->s3; - - if (! $s3) { - $this->backup->update(['save_s3' => false, 's3_storage_id' => null]); - - throw new \RuntimeException('The selected S3 storage no longer exists. S3 backup has been disabled.'); - } - $s3->testConnection(shouldSave: true); $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint, $s3->trustedInternalHosts())) ->map(fn (string $option): string => '--resolve '.escapeshellarg($option)) @@ -462,36 +485,45 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } - if ($this->backup->save_s3 && $this->backup->s3 && $this->hasRetentionLimits( + if ($this->backup->save_s3 && $this->hasRetentionLimits( $this->backup->retention_amount_s3, $this->backup->retention_days_s3, $this->backup->retention_max_storage_s3, )) { + $liveCopies = fn ($query) => $query->where('s3_uploaded', true)->where('s3_storage_deleted', false); $s3Executions = $this->backup->executions() - ->with('s3') + ->with(['s3Replicas' => fn ($query) => $liveCopies($query)->with('s3')]) ->where('status', 'success') - ->where('s3_uploaded', true) - ->where('s3_storage_deleted', false) + ->whereHas('s3Replicas', $liveCopies) ->get(); - $s3Executions = $this->executionsOutsideRetention( - $s3Executions, - $this->backup->retention_amount_s3, - $this->backup->retention_days_s3, - $this->backup->retention_max_storage_s3, - ); - foreach ($s3Executions->groupBy('s3_storage_id') as $executions) { - $s3 = $executions->first()->s3; + // Each destination keeps its own copies within the same retention settings. + foreach ($s3Executions->flatMap->s3Replicas->pluck('s3_storage_id')->unique() as $storageId) { + $executions = $this->executionsOutsideRetention( + $s3Executions->filter(fn (ScheduledVolumeBackupExecution $execution): bool => $execution->s3Replicas->contains('s3_storage_id', $storageId))->values(), + $this->backup->retention_amount_s3, + $this->backup->retention_days_s3, + $this->backup->retention_max_storage_s3, + ); + if ($executions->isEmpty()) { + continue; + } + + $replicas = $executions->map(fn (ScheduledVolumeBackupExecution $execution): VolumeBackupS3Replica => $execution->s3Replicas->firstWhere('s3_storage_id', $storageId)); + $s3 = $replicas->first()->s3; if (! $s3) { - throw new \RuntimeException('The S3 storage used by an existing backup is unavailable.'); + VolumeBackupS3Replica::query()->whereKey($replicas->pluck('id')->all())->update(['s3_storage_deleted' => true]); + $executions->each(fn (ScheduledVolumeBackupExecution $execution) => $execution->refreshS3Summary()); + + continue; } $filenames = $executions->pluck('filename')->filter()->all(); if ($filenames !== []) { deleteBackupsS3($filenames, $s3); - $this->backup->executions()->whereKey($executions->pluck('id')->all()) - ->update(['s3_storage_deleted' => true]); } + VolumeBackupS3Replica::query()->whereKey($replicas->pluck('id')->all())->update(['s3_storage_deleted' => true]); + $executions->each(fn (ScheduledVolumeBackupExecution $execution) => $execution->refreshS3Summary()); } } @@ -499,9 +531,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue ->where('local_storage_deleted', true) ->where('stop_recovery_pending', false) ->where('s3_cleanup_pending', false) - ->where(function (Builder $query): void { - $query->where('s3_storage_deleted', true)->orWhereNull('s3_uploaded'); - }) + ->withoutLiveS3Copies() ->delete(); } diff --git a/app/Jobs/VolumeBackupRecoveryJob.php b/app/Jobs/VolumeBackupRecoveryJob.php index cda0fdd046..4965df1379 100644 --- a/app/Jobs/VolumeBackupRecoveryJob.php +++ b/app/Jobs/VolumeBackupRecoveryJob.php @@ -295,12 +295,19 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou ]); } + /** + * Deletes the partial upload from every destination whose copy did not finish uploading. Every destination is + * tried; the first failure is rethrown afterwards and keeps the cleanup pending. + */ public static function cleanupS3Upload(ScheduledVolumeBackupExecution $execution): void { - $execution->loadMissing('s3'); - $s3 = $execution->s3; + $replicas = $execution->s3Replicas() + ->with('s3') + ->where('s3_storage_deleted', false) + ->where(fn ($query) => $query->whereNull('s3_uploaded')->orWhere('s3_uploaded', false)) + ->get(); - if (! $s3 || blank($execution->filename)) { + if ($replicas->isEmpty() || blank($execution->filename)) { self::skipRecovery( $execution, ['s3_cleanup_pending' => false], @@ -310,11 +317,25 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou return; } - deleteBackupsS3($execution->filename, $s3); - $execution->update([ - 's3_cleanup_pending' => false, - 's3_storage_deleted' => true, - ]); + $failure = null; + foreach ($replicas as $replica) { + try { + if ($replica->s3) { + deleteBackupsS3($execution->filename, $replica->s3); + } + $replica->update(['s3_storage_deleted' => true]); + } catch (Throwable $exception) { + $failure ??= $exception; + } + } + + $execution->refreshS3Summary(); + + if ($failure) { + throw $failure; + } + + $execution->update(['s3_cleanup_pending' => false]); } /** diff --git a/app/Livewire/Project/Application/Backup/Index.php b/app/Livewire/Project/Application/Backup/Index.php index fe7e2eac5d..7ca097db96 100644 --- a/app/Livewire/Project/Application/Backup/Index.php +++ b/app/Livewire/Project/Application/Backup/Index.php @@ -31,7 +31,7 @@ class Index extends Component public function render(): View { $backups = ScheduledVolumeBackup::query() - ->with(['backupable', 'latestExecution', 's3']) + ->with(['backupable', 'latestExecution', 's3', 's3Storages']) ->withCount('executions') ->forApplication($this->application) ->latest() diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index 8f2ff9af40..b310c248ee 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -279,6 +279,7 @@ class CloneMe extends Component 'team_id' => $teamId, ]); $newBackup->save(); + $backup->copyS3StoragesTo($newBackup); } $environmentVaribles = $database->environment_variables()->get(); @@ -469,6 +470,7 @@ class CloneMe extends Component 'team_id' => $teamId, ]); $newBackup->save(); + $backup->copyS3StoragesTo($newBackup); } } diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 59dd1a29c0..a7c49eeb58 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -9,6 +9,7 @@ use App\Models\ServiceDatabase; use App\Models\StandalonePostgresql; use App\Traits\ListensToTeamChannel; use Exception; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; @@ -76,8 +77,9 @@ class BackupEdit extends Component #[Validate(['required', 'boolean'])] public bool $disableLocalBackup = false; - #[Validate(['nullable', 'integer'])] - public ?int $s3StorageId = null; + /** @var array */ + #[Validate(['array'])] + public array $s3StorageIds = []; #[Validate(['nullable', 'string'])] public ?string $databasesToBackup = null; @@ -113,6 +115,10 @@ class BackupEdit extends Component { try { $this->authorize('view', $this->backup->database); + $this->availableS3Storages = S3Storage::query() + ->whereKey($this->availableS3StorageIds()->all()) + ->orderBy('name') + ->get(); $this->parameters = get_route_parameters(); $this->syncData(); $this->refreshStatus(); @@ -148,7 +154,6 @@ class BackupEdit extends Component $this->backup->database_backup_retention_max_storage_s3 = $this->databaseBackupRetentionMaxStorageS3; $this->backup->save_s3 = $this->saveS3; $this->backup->disable_local_backup = $this->disableLocalBackup; - $this->backup->s3_storage_id = $this->s3StorageId; // Validate databases_to_backup to prevent command injection // Handles all formats including MongoDB's "db:col1,col2|db2:col3" @@ -160,10 +165,16 @@ class BackupEdit extends Component $this->backup->dump_all = $this->dumpAll; $this->backup->timeout = $this->timeout; $this->backup->missing_backup_notification_days = $this->missingBackupNotificationDays; - $this->customValidate(); + $storageIds = $this->customValidate(); $changedFields = auditChangedFields($this->backup); $this->backup->save(); + $previousStorageIds = $this->backup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all(); + $this->backup->syncS3Storages($storageIds->all()); + if ($previousStorageIds !== $storageIds->sort()->values()->all()) { + $changedFields[] = 's3_storages'; + } + return $changedFields; } else { $this->backupEnabled = $this->backup->enabled; @@ -177,7 +188,8 @@ class BackupEdit extends Component $this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3; $this->saveS3 = $this->backup->save_s3; $this->disableLocalBackup = $this->backup->disable_local_backup ?? false; - $this->s3StorageId = $this->backup->s3_storage_id ?? $this->availableS3StorageIds()->first(); + $selectedIds = $this->backup->selectedS3Storages()->pluck('id'); + $this->s3StorageIds = ($selectedIds->isNotEmpty() ? $selectedIds : $this->availableS3StorageIds()->take(1))->all(); $this->databasesToBackup = $this->backup->databases_to_backup; $this->dumpAll = $this->backup->dump_all; $this->timeout = $this->backup->timeout; @@ -216,10 +228,10 @@ class BackupEdit extends Component if ($this->delete_associated_backups_locally && $server) { deleteBackupsLocally($filenames, $server); } + } - if ($this->delete_associated_backups_s3 && $this->backup->s3) { - deleteBackupsS3($filenames, $this->backup->s3); - } + if ($this->delete_associated_backups_s3) { + $this->backup->executions()->each(fn ($execution) => $execution->deleteS3Copies()); } $backupUuid = $this->backup->uuid; @@ -333,14 +345,14 @@ class BackupEdit extends Component } } - public function updatedS3StorageId(): void + public function updatedS3StorageIds(): void { $this->instantSave(); } public function toggleS3(): void { - if (! $this->saveS3 && $this->availableS3StorageIds()->isEmpty()) { + if (! $this->saveS3 && $this->selectedAvailableS3StorageIds()->isEmpty()) { $this->dispatch('error', 'Select a usable S3 storage before enabling S3 backups.'); return; @@ -351,21 +363,22 @@ class BackupEdit extends Component $this->instantSave(); } - private function customValidate() + /** + * @return Collection The usable S3 storage IDs to keep as destinations. + */ + private function customValidate(): Collection { - if (! is_numeric($this->backup->s3_storage_id)) { - $this->backup->s3_storage_id = null; - } + // Only usable S3 storages of the database's team can be destinations; others are dropped. + $storageIds = $this->selectedAvailableS3StorageIds(); + $this->s3StorageIds = $storageIds->all(); - // S3 backup cannot be enabled without a valid S3 storage owned by the team - $availableS3Ids = $this->availableS3StorageIds(); - if ($availableS3Ids->isEmpty()) { - $this->backup->s3_storage_id = $this->s3StorageId = null; - if ($this->backup->save_s3) { - $this->backup->save_s3 = $this->saveS3 = false; - } - } elseif (! $availableS3Ids->contains($this->backup->s3_storage_id)) { - $this->backup->s3_storage_id = $this->s3StorageId = $availableS3Ids->first(); + if ($this->availableS3StorageIds()->isEmpty()) { + $this->backup->save_s3 = $this->saveS3 = false; + } elseif ($this->backup->save_s3 && $storageIds->isEmpty()) { + $message = 'Select at least one usable S3 storage.'; + $this->addError('s3StorageIds', $message); + + throw new Exception($message); } // Validate that disable_local_backup can only be true when S3 backup is enabled @@ -378,6 +391,8 @@ class BackupEdit extends Component throw new Exception('Invalid Cron / Human expression'); } $this->validate(); + + return $storageIds; } /** @@ -399,6 +414,20 @@ class BackupEdit extends Component ]); } + /** + * @return Collection + */ + private function selectedAvailableS3StorageIds(): Collection + { + $availableIds = $this->availableS3StorageIds(); + + return collect($this->s3StorageIds) + ->map(fn ($id): int => (int) $id) + ->filter(fn (int $id): bool => $availableIds->contains($id)) + ->unique() + ->values(); + } + private function availableS3StorageIds(): Collection { $database = $this->backup->database; @@ -409,9 +438,12 @@ class BackupEdit extends Component return collect(); } + // Current destinations stay selectable while unusable, so saving other settings does not remove them. + $selectedIds = $this->backup->selectedS3Storages()->pluck('id')->all(); + return S3Storage::query() ->where('team_id', $teamId) - ->where('is_usable', true) + ->where(fn (Builder $query) => $query->where('is_usable', true)->orWhereIn('id', $selectedIds)) ->pluck('id'); } @@ -432,7 +464,7 @@ class BackupEdit extends Component return view('livewire.project.database.backup-edit', [ 'checkboxes' => [ ['id' => 'delete_associated_backups_locally', 'label' => __('database.delete_backups_locally')], - ['id' => 'delete_associated_backups_s3', 'label' => 'All backups will be permanently deleted (associated with this backup job) from the selected S3 Storage.'], + ['id' => 'delete_associated_backups_s3', 'label' => 'All backups will be permanently deleted (associated with this backup job) from every S3 storage of this schedule.'], // ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected SFTP Storage.'] ], ]); diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 6f17e2fcf5..f9d4658688 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -109,12 +109,8 @@ class BackupExecutions extends Component deleteBackupsLocally($execution->filename, $server, throwError: true); } - if ($deleteFromS3 && $execution->s3_uploaded && ! $execution->s3_storage_deleted) { - if (! $execution->scheduledDatabaseBackup->s3) { - throw new \RuntimeException('The S3 storage is unavailable.'); - } - - deleteBackupsS3($execution->filename, $execution->scheduledDatabaseBackup->s3); + if ($deleteFromS3) { + $execution->deleteS3Copies(); } $execution->delete(); diff --git a/app/Livewire/Project/Database/ScheduledBackups.php b/app/Livewire/Project/Database/ScheduledBackups.php index ece99b45f4..2071337b4b 100644 --- a/app/Livewire/Project/Database/ScheduledBackups.php +++ b/app/Livewire/Project/Database/ScheduledBackups.php @@ -73,7 +73,7 @@ class ScheduledBackups extends Component public function render(): View { - $this->database->loadMissing('scheduledBackups.s3'); + $this->database->loadMissing(['scheduledBackups.s3', 'scheduledBackups.s3Storages']); return view('livewire.project.database.scheduled-backups'); } diff --git a/app/Livewire/Project/Service/BackupExecutions.php b/app/Livewire/Project/Service/BackupExecutions.php index 81be0ff8ad..b319f66412 100644 --- a/app/Livewire/Project/Service/BackupExecutions.php +++ b/app/Livewire/Project/Service/BackupExecutions.php @@ -114,7 +114,7 @@ class BackupExecutions extends Component private function formatExecutions(Collection $rows): Collection { $databaseExecutions = ScheduledDatabaseBackupExecution::query() - ->with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.s3']) + ->with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.s3', 'scheduledDatabaseBackup.s3Storages']) ->whereIn('id', $rows->where('type', 'database')->pluck('id')) ->get()->keyBy('id'); $volumeExecutions = ScheduledVolumeBackupExecution::query() @@ -128,11 +128,16 @@ class BackupExecutions extends Component $isDatabase = $row->type === 'database'; $execution = $isDatabase ? $databaseExecutions->get($row->id) : $volumeExecutions->get($row->id); $schedule = $isDatabase ? $execution->scheduledDatabaseBackup : $execution->scheduledVolumeBackup; - $storage = $isDatabase ? ($schedule->save_s3 ? $schedule->s3 : null) : $execution->s3; - if ($storage?->team_id !== $serviceTeamId) { - $storage = null; - } - $storageLabel = $storage ? $storage->name.' (bucket: '.$storage->bucket.')' : 'Unavailable'; + $storages = match (true) { + ! $isDatabase => collect([$execution->s3]), + ! $schedule->save_s3 => collect(), + $schedule->s3Storages->isNotEmpty() => $schedule->s3Storages, + default => collect([$schedule->s3]), + }; + $storageLabel = $storages + ->filter(fn ($storage) => $storage?->team_id === $serviceTeamId) + ->map(fn ($storage) => $storage->name.' (bucket: '.$storage->bucket.')') + ->implode(', ') ?: 'Unavailable'; if ($isDatabase && ! $schedule->save_s3) { $storageLabel = 'Not configured'; } elseif (! $isDatabase && ! $execution->s3_storage_id && ! $execution->s3_uploaded && ! $execution->s3_storage_deleted) { diff --git a/app/Livewire/Project/Service/VolumeBackup/Index.php b/app/Livewire/Project/Service/VolumeBackup/Index.php index 60a78e5c15..64dbbb8df2 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Index.php +++ b/app/Livewire/Project/Service/VolumeBackup/Index.php @@ -118,7 +118,7 @@ class Index extends Component ); $databaseBackups = ScheduledDatabaseBackup::query() - ->with(['database', 'latest_log', 's3']) + ->with(['database', 'latest_log', 's3', 's3Storages']) ->withCount('executions') ->where('database_type', (new ServiceDatabase)->getMorphClass()) ->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id)) @@ -126,7 +126,7 @@ class Index extends Component ->get(); $backups = ScheduledVolumeBackup::query() - ->with(['backupable.resource', 'latestExecution', 's3']) + ->with(['backupable.resource', 'latestExecution', 's3', 's3Storages']) ->withCount('executions') ->forService($this->service) ->latest() diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 2b6f5b4587..a459b988e0 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -232,6 +232,7 @@ class ResourceOperations extends Component 'team_id' => $this->resourceTeamId(), ]); $newBackup->save(); + $backup->copyS3StoragesTo($newBackup); } $environmentVaribles = $this->resource->environment_variables()->get(); diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index d741bcc0e4..083585fee6 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -11,6 +11,7 @@ use App\Models\LocalPersistentVolume; use App\Models\S3Storage; use App\Models\ScheduledVolumeBackup; use App\Models\Service; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\RedirectResponse; use Illuminate\Routing\Redirector; @@ -43,7 +44,8 @@ class VolumeBackups extends Component public bool $stopDuringBackup = false; - public ?int $s3StorageId = null; + /** @var array */ + public array $s3StorageIds = []; public int $retentionAmountLocally = 7; @@ -88,7 +90,7 @@ class VolumeBackups extends Component 'saveToS3' => ['required', 'boolean'], 'disableLocalBackup' => ['required', 'boolean'], 'stopDuringBackup' => ['required', 'boolean'], - 's3StorageId' => ['nullable', 'integer'], + 's3StorageIds' => ['array'], 'retentionAmountLocally' => ['required', 'integer', 'min:0', 'max:10000'], 'retentionDaysLocally' => ['required', 'integer', 'min:0'], 'retentionMaxStorageLocally' => ['required', 'numeric', 'min:0'], @@ -103,12 +105,8 @@ class VolumeBackups extends Component public function mount(): void { $this->authorize('view', $this->resource); - $this->availableS3Storages = S3Storage::query() - ->where('team_id', $this->resourceTeamId()) - ->where('is_usable', true) - ->orderBy('name') - ->get(); $this->backup = $this->storage->scheduledBackups()->first(); + $this->availableS3Storages = $this->selectableS3Storages()->orderBy('name')->get(); $server = $this->backup?->server() ?? data_get($this->resource, 'destination.server'); $this->timezone = data_get($server, 'settings.server_timezone', 'Instance timezone'); @@ -118,7 +116,8 @@ class VolumeBackups extends Component $this->saveToS3 = $this->backup->save_s3; $this->disableLocalBackup = $this->backup->disable_local_backup; $this->stopDuringBackup = $this->backup->stop_during_backup; - $this->s3StorageId = $this->backup->s3_storage_id ?? $this->availableS3Storages->first()?->id; + $selectedIds = $this->backup->selectedS3Storages()->pluck('id'); + $this->s3StorageIds = ($selectedIds->isNotEmpty() ? $selectedIds : $this->availableS3Storages->take(1)->pluck('id'))->all(); $this->retentionAmountLocally = $this->backup->retention_amount_locally; $this->retentionDaysLocally = $this->backup->retention_days_locally; $this->retentionMaxStorageLocally = $this->backup->retention_max_storage_locally; @@ -128,7 +127,7 @@ class VolumeBackups extends Component $this->timeout = $this->backup->timeout; $this->missingBackupNotificationDays = $this->backup->missing_backup_notification_days; } else { - $this->s3StorageId = $this->availableS3Storages->first()?->id; + $this->s3StorageIds = $this->availableS3Storages->take(1)->pluck('id')->all(); } } @@ -149,30 +148,24 @@ class VolumeBackups extends Component $this->save(); } - public function updatedS3StorageId(): void + public function updatedS3StorageIds(): void { $this->authorize('update', $this->resource); - if (! $this->hasValidS3Storage()) { - $this->addError('s3StorageId', 'Select a usable S3 storage owned by your team.'); - - return; - } - - $this->resetErrorBag('s3StorageId'); if (! $this->validateSettings()) { return; } + $this->resetErrorBag('s3StorageIds'); $this->backup = $this->persistBackup($this->enabled); - $this->dispatch('success', 'S3 storage updated.'); + $this->dispatch('success', 'S3 storages updated.'); } public function toggleS3(): void { $this->authorize('update', $this->resource); - if (! $this->saveToS3 && ! $this->hasValidS3Storage()) { + if (! $this->saveToS3 && $this->selectedS3StorageIds()->isEmpty()) { $this->dispatch('error', 'Select a usable S3 storage before enabling S3 backups.'); return; @@ -302,6 +295,7 @@ class VolumeBackups extends Component ->where('s3_storage_deleted', true) ->orWhereNull('s3_uploaded') ->orWhere('s3_uploaded', false)) + ->withoutLiveS3Copies() ->delete() ?? 0; $this->dispatch( @@ -366,12 +360,8 @@ class VolumeBackups extends Component deleteBackupsLocally($execution->filename, $server, throwError: true); } - if ($this->delete_backup_s3 && $execution->s3_uploaded && ! $execution->s3_storage_deleted) { - if (! $execution->s3) { - throw new \RuntimeException('The S3 storage is unavailable.'); - } - - deleteBackupsS3($execution->filename, $execution->s3); + if ($this->delete_backup_s3) { + $execution->deleteS3Copies(); } $execution->delete(); @@ -388,7 +378,7 @@ class VolumeBackups extends Component public function render() { - $executions = $this->backup?->executions()->paginate($this->perPage); + $executions = $this->backup?->executions()->with('s3Replicas.s3')->paginate($this->perPage); return view('livewire.project.shared.storages.volume-backups', [ 'executions' => $executions ?? collect(), @@ -410,8 +400,8 @@ class VolumeBackups extends Component return false; } - if ($this->saveToS3 && ! $this->hasValidS3Storage()) { - $this->addError('s3StorageId', 'Select a usable S3 storage owned by your team.'); + if ($this->saveToS3 && $this->selectedS3StorageIds()->isEmpty()) { + $this->addError('s3StorageIds', 'Select at least one usable S3 storage owned by your team.'); return false; } @@ -431,7 +421,6 @@ class VolumeBackups extends Component 'save_s3' => $this->saveToS3, 'disable_local_backup' => $this->disableLocalBackup, 'stop_during_backup' => $this->stopDuringBackup, - 's3_storage_id' => $this->hasValidS3Storage() ? $this->s3StorageId : $this->backup?->s3_storage_id, 'retention_amount_locally' => $this->retentionAmountLocally, 'retention_days_locally' => $this->retentionDaysLocally, 'retention_max_storage_locally' => $this->retentionMaxStorageLocally, @@ -443,6 +432,15 @@ class VolumeBackups extends Component ]); $changedFields = auditChangedFields($backup); $backup->save(); + + $storageIds = $this->selectedS3StorageIds(); + $previousStorageIds = $backup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all(); + $backup->syncS3Storages($storageIds->all()); + $this->s3StorageIds = $storageIds->all(); + if (! $backup->wasRecentlyCreated && $previousStorageIds !== $storageIds->sort()->values()->all()) { + $changedFields[] = 's3_storages'; + } + $this->auditScheduleSet($backup, $changedFields); return $backup; @@ -485,14 +483,33 @@ class VolumeBackups extends Component ]; } - private function hasValidS3Storage(): bool + /** + * The submitted destinations that are selectable S3 storages of the resource's team. Others are dropped. + * + * @return Collection + */ + private function selectedS3StorageIds(): Collection { - return $this->s3StorageId !== null - && S3Storage::query() - ->whereKey($this->s3StorageId) - ->where('team_id', $this->resourceTeamId()) - ->where('is_usable', true) - ->exists(); + $submittedIds = collect($this->s3StorageIds)->map(fn ($id): int => (int) $id)->filter()->unique(); + + return $this->selectableS3Storages() + ->whereKey($submittedIds->all()) + ->pluck('id') + ->sortBy(fn (int $id): int|false => $submittedIds->search($id)) + ->values(); + } + + /** + * Usable storages of the resource's team, plus the current destinations. A destination that is unusable for a + * while stays selected, so saving other settings does not remove it. + */ + private function selectableS3Storages(): Builder + { + $selectedIds = $this->backup?->selectedS3Storages()->pluck('id')->all() ?? []; + + return S3Storage::query() + ->where('team_id', $this->resourceTeamId()) + ->where(fn (Builder $query) => $query->where('is_usable', true)->orWhereIn('id', $selectedIds)); } private function resourceTeamId(): int diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php index 95e7917515..306a6a4a5f 100644 --- a/app/Livewire/Storage/Resources.php +++ b/app/Livewire/Storage/Resources.php @@ -9,6 +9,7 @@ use App\Models\ScheduledVolumeBackup; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -26,7 +27,7 @@ class Resources extends Component { $this->authorize('view', $this->storage); - $backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id) + $backups = $this->backupsUsingStorage(ScheduledDatabaseBackup::query()) ->where('save_s3', true) ->get(); @@ -34,8 +35,7 @@ class Resources extends Component $this->selectedStorages[$backup->id] = $this->storage->id; } - ScheduledVolumeBackup::query() - ->where('s3_storage_id', $this->storage->id) + $this->backupsUsingStorage(ScheduledVolumeBackup::query()) ->where('save_s3', true) ->each(function (ScheduledVolumeBackup $backup): void { $this->selectedVolumeStorages[$backup->id] = $this->storage->id; @@ -46,29 +46,26 @@ class Resources extends Component { $this->authorize('update', $this->storage); - $backup = ScheduledDatabaseBackup::where('id', $backupId) - ->where('s3_storage_id', $this->storage->id) + $backup = $this->backupsUsingStorage(ScheduledDatabaseBackup::query()) + ->whereKey($backupId) ->firstOrFail(); - $backup->fill([ - 'save_s3' => false, - 's3_storage_id' => null, - ]); - $changedFields = auditChangedFields($backup); - $backup->save(); + $changedFields = $this->replaceStorage($backup, null); $this->auditDatabaseBackupUpdated($backup, $changedFields); unset($this->selectedStorages[$backupId]); - $this->dispatch('success', 'S3 disabled.', 'S3 backup has been disabled for this schedule.'); + $this->dispatch('success', 'Storage removed.', $backup->save_s3 + ? 'This storage was removed from the schedule destinations.' + : 'S3 backup has been disabled for this schedule.'); } public function moveBackup(int $backupId): void { $this->authorize('update', $this->storage); - $backup = ScheduledDatabaseBackup::where('id', $backupId) - ->where('s3_storage_id', $this->storage->id) + $backup = $this->backupsUsingStorage(ScheduledDatabaseBackup::query()) + ->whereKey($backupId) ->firstOrFail(); $newStorageId = $this->selectedStorages[$backupId] ?? null; @@ -90,9 +87,7 @@ class Resources extends Component $this->authorize('update', $newStorage); - $backup->s3_storage_id = $newStorage->id; - $changedFields = auditChangedFields($backup); - $backup->save(); + $changedFields = $this->replaceStorage($backup, $newStorage->id); $this->auditDatabaseBackupUpdated($backup, $changedFields); unset($this->selectedStorages[$backupId]); @@ -104,31 +99,26 @@ class Resources extends Component { $this->authorize('update', $this->storage); - $backup = ScheduledVolumeBackup::query() - ->where('id', $backupId) - ->where('s3_storage_id', $this->storage->id) + $backup = $this->backupsUsingStorage(ScheduledVolumeBackup::query()) + ->whereKey($backupId) ->firstOrFail(); - $backup->fill([ - 'save_s3' => false, - 's3_storage_id' => null, - ]); - $changedFields = auditChangedFields($backup); - $backup->save(); + $changedFields = $this->replaceStorage($backup, null); $this->auditVolumeBackupUpdated($backup, $changedFields); unset($this->selectedVolumeStorages[$backupId]); - $this->dispatch('success', 'S3 disabled.', 'S3 backup has been disabled for this schedule.'); + $this->dispatch('success', 'Storage removed.', $backup->save_s3 + ? 'This storage was removed from the schedule destinations.' + : 'S3 backup has been disabled for this schedule.'); } public function moveVolumeBackup(int $backupId): void { $this->authorize('update', $this->storage); - $backup = ScheduledVolumeBackup::query() - ->where('id', $backupId) - ->where('s3_storage_id', $this->storage->id) + $backup = $this->backupsUsingStorage(ScheduledVolumeBackup::query()) + ->whereKey($backupId) ->firstOrFail(); $newStorageId = $this->selectedVolumeStorages[$backupId] ?? null; @@ -151,9 +141,7 @@ class Resources extends Component $this->authorize('update', $newStorage); - $backup->s3_storage_id = $newStorage->id; - $changedFields = auditChangedFields($backup); - $backup->save(); + $changedFields = $this->replaceStorage($backup, $newStorage->id); $this->auditVolumeBackupUpdated($backup, $changedFields); unset($this->selectedVolumeStorages[$backupId]); @@ -161,6 +149,49 @@ class Resources extends Component $this->dispatch('success', 'Backup moved.', "Moved to {$newStorage->name}."); } + /** + * Schedules that have this storage among their destinations. + * + * @template TModel of ScheduledDatabaseBackup|ScheduledVolumeBackup + * + * @param Builder $query + * @return Builder + */ + private function backupsUsingStorage(Builder $query): Builder + { + return $query->usingS3Storage($this->storage->id); + } + + /** + * Replaces this storage in the destinations of a schedule, or removes it when no replacement is given. S3 backups + * turn off when no destination remains. + * + * @return array Names of the changed backup fields. + */ + private function replaceStorage(ScheduledDatabaseBackup|ScheduledVolumeBackup $backup, ?int $newStorageId): array + { + $storageIds = $backup->selectedS3Storages() + ->pluck('id') + ->map(fn (int $id): ?int => $id === $this->storage->id ? $newStorageId : $id) + ->filter() + ->unique() + ->values(); + $previousPrimaryId = $backup->s3_storage_id; + + $backup->syncS3Storages($storageIds->all()); + + $changedFields = ['s3_storages']; + if ($backup->s3_storage_id !== $previousPrimaryId) { + $changedFields[] = 's3_storage_id'; + } + if ($storageIds->isEmpty() && $backup->save_s3) { + $backup->update(['save_s3' => false]); + $changedFields[] = 'save_s3'; + } + + return $changedFields; + } + /** * @param array $changedFields */ @@ -211,7 +242,7 @@ class Resources extends Component public function render() { - $backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id) + $backups = $this->backupsUsingStorage(ScheduledDatabaseBackup::query()) ->where('save_s3', true) ->with('database') ->get() @@ -221,8 +252,7 @@ class Resources extends Component ->orderBy('name') ->get(['id', 'name', 'is_usable']); - $volumeBackups = ScheduledVolumeBackup::query() - ->where('s3_storage_id', $this->storage->id) + $volumeBackups = $this->backupsUsingStorage(ScheduledVolumeBackup::query()) ->where('save_s3', true) ->with('backupable.resource') ->get(); diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php index 17abd19e51..b7ddfcf5e2 100644 --- a/app/Livewire/Storage/Show.php +++ b/app/Livewire/Storage/Show.php @@ -32,8 +32,10 @@ class Show extends Component return $this->redirectRoute('storage.index', navigate: true); } $this->currentRoute = request()->route()->getName(); - $this->backupCount = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)->count() - + ScheduledVolumeBackup::where('s3_storage_id', $this->storage->id)->count(); + $this->backupCount = collect([ScheduledDatabaseBackup::class, ScheduledVolumeBackup::class]) + ->sum(fn (string $backupModel): int => $backupModel::query() + ->usingS3Storage($this->storage->id) + ->count()); } public function delete() diff --git a/app/Mcp/Tools/ListDatabaseBackups.php b/app/Mcp/Tools/ListDatabaseBackups.php index 7c65130935..92bee0da01 100644 --- a/app/Mcp/Tools/ListDatabaseBackups.php +++ b/app/Mcp/Tools/ListDatabaseBackups.php @@ -43,6 +43,7 @@ class ListDatabaseBackups extends Tool $backups = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId) ->where('database_id', $database->id) ->where('database_type', $database->getMorphClass()) + ->with(['s3', 's3Storages']) ->get() ->map(fn ($backup) => $this->scrubSensitive([ 'uuid' => $backup->uuid, @@ -51,6 +52,7 @@ class ListDatabaseBackups extends Tool 'database_backup_retention_amount_locally' => $backup->database_backup_retention_amount_locally ?? null, 'save_s3' => $backup->save_s3 ?? null, 's3_storage_uuid' => $backup->s3?->uuid ?? null, + 's3_storage_uuids' => $backup->selectedS3Storages()->pluck('uuid')->all(), 'created_at' => $backup->created_at, 'updated_at' => $backup->updated_at, ])) diff --git a/app/Models/DatabaseBackupS3Replica.php b/app/Models/DatabaseBackupS3Replica.php new file mode 100644 index 0000000000..1dee319c2b --- /dev/null +++ b/app/Models/DatabaseBackupS3Replica.php @@ -0,0 +1,38 @@ + 'boolean', + 's3_storage_deleted' => 'boolean', + ]; + } + + public function execution(): BelongsTo + { + return $this->belongsTo(ScheduledDatabaseBackupExecution::class, 'execution_id'); + } + + public function s3(): BelongsTo + { + return $this->belongsTo(S3Storage::class, 's3_storage_id'); + } +} diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 3c0d9e7e95..3ee7d5f269 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -6,10 +6,12 @@ use App\Rules\SafeWebhookUrl; use App\Rules\ValidS3BucketName; use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; @@ -67,19 +69,48 @@ class S3Storage extends BaseModel }); static::deleting(function (S3Storage $storage) { - ScheduledDatabaseBackup::where('s3_storage_id', $storage->id)->update([ - 'save_s3' => false, - 's3_storage_id' => null, - ]); - ScheduledVolumeBackupExecution::where('s3_storage_id', $storage->id) - ->update([ - 's3_storage_deleted' => true, - 's3_cleanup_pending' => false, - ]); - ScheduledVolumeBackup::where('s3_storage_id', $storage->id)->update([ - 'save_s3' => false, - 's3_storage_id' => null, - ]); + $storage->detachFromBackups(); + }); + } + + /** + * Removes this storage from every backup schedule and marks the copies in it as deleted. A schedule moves its + * primary destination to a remaining storage, or stops S3 backups when none remains. + */ + public function detachFromBackups(): void + { + DB::transaction(function (): void { + foreach ([ScheduledDatabaseBackup::class, ScheduledVolumeBackup::class] as $backupModel) { + $backupModel::query() + ->usingS3Storage($this->id) + ->lazyById() + ->each(function (ScheduledDatabaseBackup|ScheduledVolumeBackup $backup): void { + $backup->syncS3Storages($backup->s3Storages()->whereKeyNot($this->id)->pluck('s3_storages.id')->all()); + if ($backup->s3_storage_id === null && $backup->save_s3) { + $backup->update(['save_s3' => false]); + } + }); + } + + foreach ([DatabaseBackupS3Replica::class, VolumeBackupS3Replica::class] as $replicaModel) { + $replicaModel::query() + ->with('execution') + ->where('s3_storage_id', $this->id) + ->where('s3_storage_deleted', false) + ->lazyById() + ->each(function (DatabaseBackupS3Replica|VolumeBackupS3Replica $replica): void { + $replica->update(['s3_storage_deleted' => true]); + $replica->execution?->refreshS3Summary(); + }); + } + + ScheduledVolumeBackupExecution::query() + ->where('s3_cleanup_pending', true) + ->whereHas('s3Replicas', fn (Builder $query) => $query->where('s3_storage_id', $this->id)) + ->whereDoesntHave('s3Replicas', fn (Builder $query) => $query + ->where('s3_storage_deleted', false) + ->where(fn (Builder $query) => $query->whereNull('s3_uploaded')->orWhere('s3_uploaded', false))) + ->update(['s3_cleanup_pending' => false]); }); } diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index 53ae084349..3c31c0fbc7 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -2,12 +2,16 @@ namespace App\Models; +use App\Traits\HasS3Destinations; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphTo; class ScheduledDatabaseBackup extends BaseModel { + use HasS3Destinations; + protected function casts(): array { return [ @@ -78,6 +82,11 @@ class ScheduledDatabaseBackup extends BaseModel return $this->belongsTo(S3Storage::class, 's3_storage_id'); } + public function s3Storages(): BelongsToMany + { + return $this->belongsToMany(S3Storage::class, 'scheduled_database_backup_s3_storage')->withTimestamps(); + } + public function get_last_days_backup_status($days = 7) { return $this->hasMany(ScheduledDatabaseBackupExecution::class)->where('created_at', '>=', now()->subDays($days))->get(); @@ -85,7 +94,7 @@ class ScheduledDatabaseBackup extends BaseModel public function executionsPaginated(int $skip = 0, int $take = 10) { - $executions = $this->hasMany(ScheduledDatabaseBackupExecution::class)->orderBy('created_at', 'desc'); + $executions = $this->hasMany(ScheduledDatabaseBackupExecution::class)->with('s3Replicas.s3')->orderBy('created_at', 'desc'); $count = $executions->count(); $executions = $executions->skip($skip)->take($take)->get(); diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php index 1a479772cf..eb43a1a2e3 100644 --- a/app/Models/ScheduledDatabaseBackupExecution.php +++ b/app/Models/ScheduledDatabaseBackupExecution.php @@ -2,10 +2,14 @@ namespace App\Models; +use App\Traits\HasS3Replicas; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class ScheduledDatabaseBackupExecution extends BaseModel { + use HasS3Replicas; + protected static function booted(): void { static::created(function (ScheduledDatabaseBackupExecution $execution): void { @@ -42,4 +46,9 @@ class ScheduledDatabaseBackupExecution extends BaseModel { return $this->belongsTo(ScheduledDatabaseBackup::class); } + + public function s3Replicas(): HasMany + { + return $this->hasMany(DatabaseBackupS3Replica::class, 'execution_id'); + } } diff --git a/app/Models/ScheduledVolumeBackup.php b/app/Models/ScheduledVolumeBackup.php index 9ad676d953..0c4443b2e3 100644 --- a/app/Models/ScheduledVolumeBackup.php +++ b/app/Models/ScheduledVolumeBackup.php @@ -2,15 +2,19 @@ namespace App\Models; +use App\Traits\HasS3Destinations; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphTo; class ScheduledVolumeBackup extends BaseModel { + use HasS3Destinations; + public const int DEFAULT_TIMEOUT = 36000; protected $fillable = [ @@ -123,6 +127,11 @@ class ScheduledVolumeBackup extends BaseModel return $this->belongsTo(S3Storage::class, 's3_storage_id'); } + public function s3Storages(): BelongsToMany + { + return $this->belongsToMany(S3Storage::class, 'scheduled_volume_backup_s3_storage')->withTimestamps(); + } + public function executions(): HasMany { return $this->hasMany(ScheduledVolumeBackupExecution::class)->latest(); diff --git a/app/Models/ScheduledVolumeBackupExecution.php b/app/Models/ScheduledVolumeBackupExecution.php index 664efa11c1..82dfe0ee3b 100644 --- a/app/Models/ScheduledVolumeBackupExecution.php +++ b/app/Models/ScheduledVolumeBackupExecution.php @@ -2,10 +2,14 @@ namespace App\Models; +use App\Traits\HasS3Replicas; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class ScheduledVolumeBackupExecution extends BaseModel { + use HasS3Replicas; + protected $fillable = [ 'uuid', 'scheduled_volume_backup_id', @@ -56,4 +60,9 @@ class ScheduledVolumeBackupExecution extends BaseModel { return $this->belongsTo(S3Storage::class, 's3_storage_id'); } + + public function s3Replicas(): HasMany + { + return $this->hasMany(VolumeBackupS3Replica::class, 'execution_id'); + } } diff --git a/app/Models/VolumeBackupS3Replica.php b/app/Models/VolumeBackupS3Replica.php new file mode 100644 index 0000000000..779ee7571c --- /dev/null +++ b/app/Models/VolumeBackupS3Replica.php @@ -0,0 +1,38 @@ + 'boolean', + 's3_storage_deleted' => 'boolean', + ]; + } + + public function execution(): BelongsTo + { + return $this->belongsTo(ScheduledVolumeBackupExecution::class, 'execution_id'); + } + + public function s3(): BelongsTo + { + return $this->belongsTo(S3Storage::class, 's3_storage_id'); + } +} diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index 8db6e5afd1..db7ddf556e 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -16,6 +16,7 @@ use App\Models\Project; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledTask; +use App\Models\ScheduledVolumeBackup; use App\Models\Server; use App\Models\Service; use App\Models\SharedEnvironmentVariable; @@ -202,22 +203,22 @@ class ServerTransferExporter } } - $collectVolumeS3 = function ($resource) use (&$s3Ids): void { + $collectBackupS3 = function (ScheduledDatabaseBackup|ScheduledVolumeBackup $backup) use (&$s3Ids): void { + $s3Ids->push($backup->s3_storage_id, ...$backup->s3Storages()->pluck('s3_storages.id')->all()); + }; + + $collectVolumeS3 = function ($resource) use ($collectBackupS3): void { if (method_exists($resource, 'persistentStorages')) { foreach ($resource->persistentStorages as $volume) { foreach ($volume->scheduledBackups as $vb) { - if ($vb->s3_storage_id) { - $s3Ids->push($vb->s3_storage_id); - } + $collectBackupS3($vb); } } } if (method_exists($resource, 'fileStorages')) { foreach ($resource->fileStorages as $file) { foreach ($file->scheduledBackups as $vb) { - if ($vb->s3_storage_id) { - $s3Ids->push($vb->s3_storage_id); - } + $collectBackupS3($vb); } } } @@ -226,9 +227,7 @@ class ServerTransferExporter foreach ($databases as $database) { if (method_exists($database, 'scheduledBackups')) { foreach ($database->scheduledBackups as $backup) { - if ($backup->s3_storage_id) { - $s3Ids->push($backup->s3_storage_id); - } + $collectBackupS3($backup); } } $database->loadMissing(['persistentStorages.scheduledBackups', 'fileStorages.scheduledBackups']); @@ -257,9 +256,7 @@ class ServerTransferExporter } foreach ($service->databases as $serviceDb) { foreach ($serviceDb->scheduledBackups as $backup) { - if ($backup->s3_storage_id) { - $s3Ids->push($backup->s3_storage_id); - } + $collectBackupS3($backup); } $collectVolumeS3($serviceDb); } @@ -981,6 +978,7 @@ class ServerTransferExporter 'timeout' => $backup->timeout, 'disable_local_backup' => (bool) ($backup->disable_local_backup ?? false), 's3_storage_uuid' => $backup->s3?->uuid, + 's3_storage_uuids' => $backup->selectedS3Storages()->pluck('uuid')->values()->all(), 'had_s3_storage' => filled($backup->s3_storage_id), ]; } @@ -1193,6 +1191,7 @@ class ServerTransferExporter 'retention_max_storage_s3' => $backup->retention_max_storage_s3, 'timeout' => $backup->timeout, 's3_storage_uuid' => $backup->s3?->uuid, + 's3_storage_uuids' => $backup->selectedS3Storages()->pluck('uuid')->values()->all(), 'had_s3_storage' => filled($backup->s3_storage_id), ]; } diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php index 90fe5954d0..d1714d4e70 100644 --- a/app/Services/ServerTransfer/ServerTransferImporter.php +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -1237,13 +1237,10 @@ class ServerTransferImporter $uuid = new_public_id(); } - $s3Id = null; - $s3Uuid = data_get($backup, 's3_storage_uuid'); - if ($s3Uuid && isset($this->s3StorageMap[$s3Uuid])) { - $s3Id = $this->s3StorageMap[$s3Uuid]->id; - } + $s3Ids = $this->importedBackupS3StorageIds($backup); + $s3Id = $s3Ids[0] ?? null; - ScheduledDatabaseBackup::create([ + $importedBackup = ScheduledDatabaseBackup::create([ 'uuid' => $uuid, 'team_id' => $teamId, 'description' => data_get($backup, 'description'), @@ -1264,9 +1261,26 @@ class ServerTransferImporter 'database_type' => $database->getMorphClass(), 'database_id' => $database->id, ]); + $importedBackup->syncS3Storages($s3Ids); } } + /** + * Maps the exported S3 destinations of a backup to imported storages, primary first. Older bundles only have + * `s3_storage_uuid`. + * + * @param array $backup + * @return list + */ + private function importedBackupS3StorageIds(array $backup): array + { + $uuids = collect(data_get($backup, 's3_storage_uuids', [])) + ->prepend(data_get($backup, 's3_storage_uuid')) + ->filter(fn ($uuid): bool => is_string($uuid) && isset($this->s3StorageMap[$uuid])); + + return $uuids->map(fn (string $uuid): int => $this->s3StorageMap[$uuid]->id)->unique()->values()->all(); + } + /** * @param array $payload */ @@ -1612,13 +1626,10 @@ class ServerTransferImporter $uuid = new_public_id(); } - $s3Id = null; - $s3Uuid = data_get($backupPayload, 's3_storage_uuid'); - if ($s3Uuid && isset($this->s3StorageMap[$s3Uuid])) { - $s3Id = $this->s3StorageMap[$s3Uuid]->id; - } + $s3Ids = $this->importedBackupS3StorageIds($backupPayload); + $s3Id = $s3Ids[0] ?? null; - ScheduledVolumeBackup::create([ + $importedBackup = ScheduledVolumeBackup::create([ 'uuid' => $uuid, 'backupable_type' => $volume->getMorphClass(), 'backupable_id' => $volume->id, @@ -1637,6 +1648,7 @@ class ServerTransferImporter 'retention_max_storage_s3' => data_get($backupPayload, 'retention_max_storage_s3'), 'timeout' => data_get($backupPayload, 'timeout'), ]); + $importedBackup->syncS3Storages($s3Ids); $count++; } diff --git a/app/Traits/HasS3Destinations.php b/app/Traits/HasS3Destinations.php new file mode 100644 index 0000000000..59668f3961 --- /dev/null +++ b/app/Traits/HasS3Destinations.php @@ -0,0 +1,68 @@ + The primary destination comes first. + */ + public function selectedS3Storages(): Collection + { + $storages = $this->relationLoaded('s3Storages') + ? $this->s3Storages->sortBy('name') + : $this->s3Storages()->orderBy('s3_storages.name')->get(); + + if ($storages->isEmpty()) { + return $this->s3 ? collect([$this->s3]) : collect(); + } + + return $storages + ->sortBy(fn (S3Storage $storage): int => $storage->id === $this->s3_storage_id ? 0 : 1) + ->values(); + } + + /** + * Schedules that send backups to the given storage. + */ + public function scopeUsingS3Storage(Builder $query, int $storageId): Builder + { + return $query->where(fn (Builder $query) => $query + ->where('s3_storage_id', $storageId) + ->orWhereHas('s3Storages', fn (Builder $query) => $query->whereKey($storageId))); + } + + /** + * Gives a cloned schedule the same destinations, limited to storages of the clone's team. + */ + public function copyS3StoragesTo(self $copy): void + { + $copy->s3Storages()->sync( + $this->s3Storages()->where('s3_storages.team_id', $copy->team_id)->pluck('s3_storages.id')->all(), + ); + } + + /** + * Replaces the selected destinations. The current primary destination stays primary when it is still selected. + * + * @param array $storageIds + */ + public function syncS3Storages(array $storageIds): void + { + $storageIds = collect($storageIds)->map(fn ($id): int => (int) $id)->filter()->unique()->values(); + + $this->s3Storages()->sync($storageIds->all()); + $this->s3_storage_id = $storageIds->contains($this->s3_storage_id) ? $this->s3_storage_id : $storageIds->first(); + $this->save(); + } +} diff --git a/app/Traits/HasS3Replicas.php b/app/Traits/HasS3Replicas.php new file mode 100644 index 0000000000..81e935e174 --- /dev/null +++ b/app/Traits/HasS3Replicas.php @@ -0,0 +1,75 @@ +s3Replicas() + ->with('s3') + ->where('s3_uploaded', true) + ->where('s3_storage_deleted', false) + ->get(); + + try { + foreach ($replicas as $replica) { + if (! $replica->s3) { + throw new \RuntimeException('The S3 storage used by an existing backup is unavailable.'); + } + if (filled($this->filename)) { + deleteBackupsS3($this->filename, $replica->s3); + } + $replica->update(['s3_storage_deleted' => true]); + } + } finally { + $this->refreshS3Summary(); + } + } + + public function hasLiveS3Copies(): bool + { + return $this->s3Replicas()->where('s3_uploaded', true)->where('s3_storage_deleted', false)->exists(); + } + + /** + * Writes the replica states to the summary columns: uploaded when every copy uploaded, deleted when every uploaded + * copy is deleted. + */ + public function refreshS3Summary(): void + { + $replicas = $this->s3Replicas()->get(); + if ($replicas->isEmpty()) { + return; + } + + $uploaded = $replicas->where('s3_uploaded', true); + + $this->update([ + 's3_uploaded' => $replicas->every(fn ($replica): bool => $replica->s3_uploaded === true), + 's3_storage_deleted' => $uploaded->isNotEmpty() && $uploaded->every(fn ($replica): bool => $replica->s3_storage_deleted), + ]); + } + + /** + * Executions that have no S3 copy left to keep. + */ + public function scopeWithoutLiveS3Copies(Builder $query): Builder + { + return $query->whereDoesntHave('s3Replicas', fn (Builder $query) => $query + ->where('s3_uploaded', true) + ->where('s3_storage_deleted', false)); + } +} diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index f4a97be5ce..966b2de3e6 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -1,7 +1,9 @@ executions) { - // Delete old local backups (only if local backup is NOT disabled) - // Note: When disable_local_backup is enabled, each execution already marks its own - // local_storage_deleted status at the time of backup, so we don't need to retroactively - // update old executions - if (! $backup->disable_local_backup) { - $localBackupsToDelete = deleteOldBackupsLocally($backup); - if ($localBackupsToDelete->isNotEmpty()) { - $backup->executions() - ->whereIn('id', $localBackupsToDelete->pluck('id')) - ->update(['local_storage_deleted' => true]); - } - } - } - - if ($backup->save_s3 && $backup->executions) { - $s3BackupsToDelete = deleteOldBackupsFromS3($backup); - if ($s3BackupsToDelete->isNotEmpty()) { - $backup->executions() - ->whereIn('id', $s3BackupsToDelete->pluck('id')) - ->update(['s3_storage_deleted' => true]); - } - } - - // Delete execution records where all backup copies are gone - // Case 1: Both local and S3 backups are deleted + // When local backups are disabled, retain the local file until an S3 copy is available. + $localBackupsToDelete = deleteOldBackupsLocally($backup); + if ($localBackupsToDelete->isNotEmpty()) { $backup->executions() - ->where('local_storage_deleted', true) - ->where('s3_storage_deleted', true) - ->delete(); - - // Case 2: Local backup is deleted and S3 was never used (s3_uploaded is null) - $backup->executions() - ->where('local_storage_deleted', true) - ->whereNull('s3_uploaded') - ->delete(); - - } catch (Exception $e) { - throw $e; + ->whereIn('id', $localBackupsToDelete->pluck('id')) + ->update(['local_storage_deleted' => true]); } + + if ($backup->save_s3) { + deleteOldBackupsFromS3($backup); + } + + // Delete execution records when no local file and no S3 copy is left + $backup->executions() + ->where('local_storage_deleted', true) + ->withoutLiveS3Copies() + ->delete(); } -function deleteOldBackupsLocally($backup): Collection +/** + * Successful executions that fall outside the retention settings, newest execution first. + * + * @param Collection $executions Sorted newest first. + * @return Collection + */ +function backupExecutionsOutsideRetention(Collection $executions, ?int $retentionAmount, ?int $retentionDays, int|float|null $maxStorageGB): Collection { - if (! $backup || ! $backup->executions) { - return collect(); - } - - $successfulBackups = $backup->executions() - ->where('status', 'success') - ->where('local_storage_deleted', false) - ->orderBy('created_at', 'desc') - ->get(); - - if ($successfulBackups->isEmpty()) { - return collect(); - } - - $retentionAmount = $backup->database_backup_retention_amount_locally; - $retentionDays = $backup->database_backup_retention_days_locally; - $maxStorageGB = $backup->database_backup_retention_max_storage_locally; - - if ($retentionAmount === 0 && $retentionDays === 0 && $maxStorageGB === 0) { + if ($executions->isEmpty()) { return collect(); } $backupsToDelete = collect(); if ($retentionAmount > 0) { - $byAmount = $successfulBackups->skip($retentionAmount); - $backupsToDelete = $backupsToDelete->merge($byAmount); + $backupsToDelete = $backupsToDelete->merge($executions->skip($retentionAmount)); } if ($retentionDays > 0) { - $oldestAllowedDate = $successfulBackups->first()->created_at->clone()->utc()->subDays($retentionDays); - $oldBackups = $successfulBackups->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate); - $backupsToDelete = $backupsToDelete->merge($oldBackups); + $oldestAllowedDate = $executions->first()->created_at->clone()->utc()->subDays($retentionDays); + $backupsToDelete = $backupsToDelete->merge( + $executions->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate) + ); } if ($maxStorageGB > 0) { $maxStorageBytes = $maxStorageGB * pow(1024, 3); $totalSize = 0; - $backupsOverLimit = collect(); - $backupsToCheck = $successfulBackups->skip(1); - - foreach ($backupsToCheck as $backupExecution) { + foreach ($executions->skip(1) as $backupExecution) { $totalSize += (int) $backupExecution->size; if ($totalSize > $maxStorageBytes) { - $backupsOverLimit = $successfulBackups->filter( + $backupsToDelete = $backupsToDelete->merge($executions->filter( fn ($b) => $b->created_at->utc() <= $backupExecution->created_at->utc() - )->skip(1); + )->skip(1)); break; } } - - $backupsToDelete = $backupsToDelete->merge($backupsOverLimit); } - $backupsToDelete = $backupsToDelete->unique('id'); - $processedBackups = collect(); + return $backupsToDelete->unique('id')->values(); +} + +function deleteOldBackupsLocally($backup): Collection +{ + if (! $backup) { + return collect(); + } + + $successfulBackups = $backup->executions() + ->where('status', 'success') + ->where('local_storage_deleted', false) + ->when($backup->disable_local_backup, fn ($query) => $query + ->whereHas('s3Replicas', fn ($query) => $query + ->where('s3_uploaded', true) + ->where('s3_storage_deleted', false))) + ->orderBy('created_at', 'desc') + ->orderBy('id', 'desc') + ->get(); + + $backupsToDelete = backupExecutionsOutsideRetention( + $successfulBackups, + $backup->database_backup_retention_amount_locally, + $backup->database_backup_retention_days_locally, + $backup->database_backup_retention_max_storage_locally, + ); + + if ($backupsToDelete->isEmpty()) { + return collect(); + } - $server = null; if ($backup->database_type === ServiceDatabase::class) { $server = $backup->database->service->server; } else { @@ -433,89 +421,81 @@ function deleteOldBackupsLocally($backup): Collection ->pluck('filename') ->all(); - if (! empty($filesToDelete)) { - deleteBackupsLocally($filesToDelete, $server); - $processedBackups = $backupsToDelete; + if (empty($filesToDelete)) { + return collect(); } - return $processedBackups; + deleteBackupsLocally($filesToDelete, $server); + + return $backupsToDelete; } +/** + * Applies the S3 retention settings to each destination separately. Copies in a storage that no longer exists are + * marked as deleted. + * + * @return Collection The executions that lost at least one S3 copy. + */ function deleteOldBackupsFromS3($backup): Collection { - if (! $backup || ! $backup->executions || ! $backup->s3) { + if (! $backup) { return collect(); } - $successfulBackups = $backup->executions() - ->where('status', 'success') + $liveReplicas = DatabaseBackupS3Replica::query() + ->with('s3') + ->whereHas('execution', fn ($query) => $query + ->where('scheduled_database_backup_id', $backup->id) + ->where('status', 'success')) + ->where('s3_uploaded', true) ->where('s3_storage_deleted', false) - ->orderBy('created_at', 'desc') ->get(); - if ($successfulBackups->isEmpty()) { - return collect(); - } + $deletedReplicas = $liveReplicas->filter(fn (DatabaseBackupS3Replica $replica) => ! $replica->s3); - $retentionAmount = $backup->database_backup_retention_amount_s3; - $retentionDays = $backup->database_backup_retention_days_s3; - $maxStorageGB = $backup->database_backup_retention_max_storage_s3; + foreach ($liveReplicas->filter(fn (DatabaseBackupS3Replica $replica) => $replica->s3)->groupBy('s3_storage_id') as $replicas) { + $successfulBackups = $backup->executions() + ->whereIn('id', $replicas->pluck('execution_id')) + ->orderBy('created_at', 'desc') + ->orderBy('id', 'desc') + ->get(); - if ($retentionAmount === 0 && $retentionDays === 0 && $maxStorageGB === 0) { - return collect(); - } + $backupsToDelete = backupExecutionsOutsideRetention( + $successfulBackups, + $backup->database_backup_retention_amount_s3, + $backup->database_backup_retention_days_s3, + $backup->database_backup_retention_max_storage_s3, + ); - $backupsToDelete = collect(); + $filesToDelete = $backupsToDelete + ->filter(fn ($execution) => ! empty($execution->filename)) + ->pluck('filename') + ->all(); - if ($retentionAmount > 0) { - $byAmount = $successfulBackups->skip($retentionAmount); - $backupsToDelete = $backupsToDelete->merge($byAmount); - } - - if ($retentionDays > 0) { - $oldestAllowedDate = $successfulBackups->first()->created_at->clone()->utc()->subDays($retentionDays); - $oldBackups = $successfulBackups->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate); - $backupsToDelete = $backupsToDelete->merge($oldBackups); - } - - if ($maxStorageGB > 0) { - $maxStorageBytes = $maxStorageGB * pow(1024, 3); - $totalSize = 0; - $backupsOverLimit = collect(); - - $backupsToCheck = $successfulBackups->skip(1); - - foreach ($backupsToCheck as $backupExecution) { - $totalSize += (int) $backupExecution->size; - if ($totalSize > $maxStorageBytes) { - $backupsOverLimit = $successfulBackups->filter( - fn ($b) => $b->created_at->utc() <= $backupExecution->created_at->utc() - )->skip(1); - break; - } + if (empty($filesToDelete)) { + continue; } - $backupsToDelete = $backupsToDelete->merge($backupsOverLimit); - } - - $backupsToDelete = $backupsToDelete->unique('id'); - $processedBackups = collect(); - - $filesToDelete = $backupsToDelete - ->filter(fn ($execution) => ! empty($execution->filename)) - ->pluck('filename') - ->all(); - - if (! empty($filesToDelete)) { try { - deleteBackupsS3($filesToDelete, $backup->s3); - $processedBackups = $backupsToDelete; + deleteBackupsS3($filesToDelete, $replicas->first()->s3); + $deletedReplicas = $deletedReplicas->merge($replicas->whereIn('execution_id', $backupsToDelete->pluck('id'))); } catch (Throwable $e) { report($e); } } - return $processedBackups; + if ($deletedReplicas->isEmpty()) { + return collect(); + } + + DatabaseBackupS3Replica::query() + ->whereKey($deletedReplicas->pluck('id')) + ->update(['s3_storage_deleted' => true]); + + $executions = $backup->executions()->whereIn('id', $deletedReplicas->pluck('execution_id')->unique())->get(); + $executions->each(fn (ScheduledDatabaseBackupExecution $execution) => $execution->refreshS3Summary()); + + return $executions; } function isPublicPortAlreadyUsed(Server $server, int $port, ?string $id = null): bool diff --git a/database/migrations/2026_10_07_150000_create_backup_s3_destination_tables.php b/database/migrations/2026_10_07_150000_create_backup_s3_destination_tables.php new file mode 100644 index 0000000000..39aff70ad2 --- /dev/null +++ b/database/migrations/2026_10_07_150000_create_backup_s3_destination_tables.php @@ -0,0 +1,126 @@ +id(); + $table->foreignId('scheduled_database_backup_id') + ->constrained(indexName: 'db_backup_s3_storage_backup_fk') + ->cascadeOnDelete(); + $table->foreignId('s3_storage_id') + ->constrained(indexName: 'db_backup_s3_storage_storage_fk') + ->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['scheduled_database_backup_id', 's3_storage_id'], 'db_backup_s3_storage_unique'); + }); + + Schema::create('scheduled_volume_backup_s3_storage', function (Blueprint $table) { + $table->id(); + $table->foreignId('scheduled_volume_backup_id') + ->constrained(indexName: 'volume_backup_s3_storage_backup_fk') + ->cascadeOnDelete(); + $table->foreignId('s3_storage_id') + ->constrained(indexName: 'volume_backup_s3_storage_storage_fk') + ->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['scheduled_volume_backup_id', 's3_storage_id'], 'volume_backup_s3_storage_unique'); + }); + + Schema::create('database_backup_s3_replicas', function (Blueprint $table) { + $table->id(); + $table->foreignId('execution_id') + ->index() + ->constrained('scheduled_database_backup_executions', indexName: 'db_backup_s3_replicas_execution_fk') + ->cascadeOnDelete(); + $table->foreignId('s3_storage_id') + ->nullable() + ->constrained(indexName: 'db_backup_s3_replicas_storage_fk') + ->nullOnDelete(); + $table->boolean('s3_uploaded')->nullable(); + $table->boolean('s3_storage_deleted')->default(false); + $table->text('message')->nullable(); + $table->timestamps(); + }); + + Schema::create('volume_backup_s3_replicas', function (Blueprint $table) { + $table->id(); + $table->foreignId('execution_id') + ->index() + ->constrained('scheduled_volume_backup_executions', indexName: 'volume_backup_s3_replicas_execution_fk') + ->cascadeOnDelete(); + $table->foreignId('s3_storage_id') + ->nullable() + ->constrained(indexName: 'volume_backup_s3_replicas_storage_fk') + ->nullOnDelete(); + $table->boolean('s3_uploaded')->nullable(); + $table->boolean('s3_storage_deleted')->default(false); + $table->text('message')->nullable(); + $table->timestamps(); + }); + + $this->backfill(); + } + + public function down(): void + { + Schema::dropIfExists('volume_backup_s3_replicas'); + Schema::dropIfExists('database_backup_s3_replicas'); + Schema::dropIfExists('scheduled_volume_backup_s3_storage'); + Schema::dropIfExists('scheduled_database_backup_s3_storage'); + } + + /** + * Copies the single S3 destination of existing schedules and executions into the new tables. + */ + private function backfill(): void + { + $now = now(); + + DB::table('scheduled_database_backup_s3_storage')->insertUsing( + ['scheduled_database_backup_id', 's3_storage_id', 'created_at', 'updated_at'], + DB::table('scheduled_database_backups') + ->join('s3_storages', 's3_storages.id', '=', 'scheduled_database_backups.s3_storage_id') + ->select('scheduled_database_backups.id', 'scheduled_database_backups.s3_storage_id') + ->selectRaw('?, ?', [$now, $now]), + ); + + DB::table('scheduled_volume_backup_s3_storage')->insertUsing( + ['scheduled_volume_backup_id', 's3_storage_id', 'created_at', 'updated_at'], + DB::table('scheduled_volume_backups') + ->whereNotNull('s3_storage_id') + ->select('id', 's3_storage_id') + ->selectRaw('?, ?', [$now, $now]), + ); + + // Database executions have no storage column: the copy went to the storage of the schedule. + DB::table('database_backup_s3_replicas')->insertUsing( + ['execution_id', 's3_storage_id', 's3_uploaded', 's3_storage_deleted', 'created_at', 'updated_at'], + DB::table('scheduled_database_backup_executions') + ->join('scheduled_database_backups', 'scheduled_database_backups.id', '=', 'scheduled_database_backup_executions.scheduled_database_backup_id') + ->leftJoin('s3_storages', 's3_storages.id', '=', 'scheduled_database_backups.s3_storage_id') + ->whereNotNull('scheduled_database_backup_executions.s3_uploaded') + ->select( + 'scheduled_database_backup_executions.id', + 's3_storages.id', + 'scheduled_database_backup_executions.s3_uploaded', + 'scheduled_database_backup_executions.s3_storage_deleted', + ) + ->selectRaw('?, ?', [$now, $now]), + ); + + DB::table('volume_backup_s3_replicas')->insertUsing( + ['execution_id', 's3_storage_id', 's3_uploaded', 's3_storage_deleted', 'created_at', 'updated_at'], + DB::table('scheduled_volume_backup_executions') + ->whereNotNull('s3_storage_id') + ->select('id', 's3_storage_id', 's3_uploaded', 's3_storage_deleted') + ->selectRaw('?, ?', [$now, $now]), + ); + } +}; diff --git a/openapi.json b/openapi.json index 192d6f49b9..e418f93489 100644 --- a/openapi.json +++ b/openapi.json @@ -86,9 +86,9 @@ "properties": { "pull_request_id": { "type": "integer", + "example": 42, "maximum": 2147483647, - "minimum": 1, - "example": 42 + "minimum": 1 }, "pull_request_html_url": { "type": [ @@ -6857,7 +6857,7 @@ ], "responses": { "200": { - "description": "Get all backups for a database", + "description": "Get all backups for a database. Each backup includes s3_storage_uuid (primary S3 destination) and s3_storage_uuids (every S3 destination, primary first).", "content": { "application\/json": { "schema": { @@ -6927,7 +6927,14 @@ }, "s3_storage_uuid": { "type": "string", - "description": "S3 storage UUID (required if save_s3 is true)" + "description": "Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination. Either field is required if save_s3 is true." + }, + "s3_storage_uuids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination." }, "databases_to_backup": { "type": "string", @@ -7530,7 +7537,14 @@ }, "s3_storage_uuid": { "type": "string", - "description": "S3 storage UUID" + "description": "Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination; when it is already a destination, it becomes the primary destination and the other destinations stay." + }, + "s3_storage_uuids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination." }, "backup_now": { "type": "boolean", @@ -10878,8 +10892,8 @@ }, "network": { "type": "string", - "maxLength": 255, - "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$", + "maxLength": 255 }, "type": { "type": "string", @@ -23541,8 +23555,8 @@ "properties": { "frequency": { "type": "string", - "maxLength": 255, - "example": "0 2 * * *" + "example": "0 2 * * *", + "maxLength": 255 }, "enabled": { "type": "boolean", @@ -23561,11 +23575,19 @@ "default": false }, "s3_storage_uuid": { + "description": "Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination.", "type": [ "string", "null" ] }, + "s3_storage_uuids": { + "description": "UUIDs of every S3 storage the backup is uploaded to. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination. save_s3 requires at least one destination.", + "type": "array", + "items": { + "type": "string" + } + }, "retention_amount_locally": { "type": "integer", "default": 7, @@ -23674,11 +23696,19 @@ "type": "boolean" }, "s3_storage_uuid": { + "description": "Primary S3 destination.", "type": [ "string", "null" ] }, + "s3_storage_uuids": { + "description": "Every S3 destination, primary first.", + "type": "array", + "items": { + "type": "string" + } + }, "retention_amount_locally": { "type": "integer" }, diff --git a/openapi.yaml b/openapi.yaml index 5983cbe38d..a7e4087848 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -62,9 +62,9 @@ paths: properties: pull_request_id: type: integer + example: 42 maximum: 2147483647 minimum: 1 - example: 42 pull_request_html_url: type: [string, 'null'] example: 'https://github.com/org/repo/pull/42' @@ -4401,7 +4401,7 @@ paths: type: string responses: '200': - description: 'Get all backups for a database' + description: 'Get all backups for a database. Each backup includes s3_storage_uuid (primary S3 destination) and s3_storage_uuids (every S3 destination, primary first).' content: application/json: schema: @@ -4452,7 +4452,11 @@ paths: default: false s3_storage_uuid: type: string - description: 'S3 storage UUID (required if save_s3 is true)' + description: 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination. Either field is required if save_s3 is true.' + s3_storage_uuids: + type: array + items: { type: string } + description: 'UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination.' databases_to_backup: type: string description: 'Comma separated list of databases to backup' @@ -4875,7 +4879,11 @@ paths: description: 'Whether data is saved in s3 or not' s3_storage_uuid: type: string - description: 'S3 storage UUID' + description: 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination; when it is already a destination, it becomes the primary destination and the other destinations stay.' + s3_storage_uuids: + type: array + items: { type: string } + description: 'UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination.' backup_now: type: boolean description: 'Whether to take a backup now or not' @@ -7099,8 +7107,8 @@ paths: maxLength: 255 network: type: string - maxLength: 255 pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$' + maxLength: 255 type: type: string enum: [standalone, swarm] @@ -14947,8 +14955,8 @@ components: properties: frequency: type: string - maxLength: 255 example: '0 2 * * *' + maxLength: 255 enabled: type: boolean default: true @@ -14962,9 +14970,15 @@ components: type: boolean default: false s3_storage_uuid: + description: 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination.' type: - string - 'null' + s3_storage_uuids: + description: 'UUIDs of every S3 storage the backup is uploaded to. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination. save_s3 requires at least one destination.' + type: array + items: + type: string retention_amount_locally: type: integer default: 7 @@ -15052,9 +15066,15 @@ components: stop_during_backup: type: boolean s3_storage_uuid: + description: 'Primary S3 destination.' type: - string - 'null' + s3_storage_uuids: + description: 'Every S3 destination, primary first.' + type: array + items: + type: string retention_amount_locally: type: integer retention_days_locally: diff --git a/resources/views/components/status-badge.blade.php b/resources/views/components/status-badge.blade.php index 5aa02454bd..a8f49e8bf5 100644 --- a/resources/views/components/status-badge.blade.php +++ b/resources/views/components/status-badge.blade.php @@ -4,6 +4,7 @@ 'type' => 'neutral', 'as' => 'span', 'dynamic' => false, + 'pulse' => false, // ping the dot for a state that is still in progress ]) @php @@ -22,7 +23,12 @@ @if ($dynamic) {{ $slot }} @else - + + @if ($pulse) + + @endif + + {{ collect([$label, $status])->filter()->join(' ') }} @endif @@ -31,7 +37,12 @@ @if ($dynamic) {{ $slot }} @else - + + @if ($pulse) + + @endif + + {{ collect([$label, $status])->filter()->join(' ') }} @endif @@ -40,7 +51,12 @@ @if ($dynamic) {{ $slot }} @else - + + @if ($pulse) + + @endif + + {{ collect([$label, $status])->filter()->join(' ') }} @endif diff --git a/resources/views/livewire/project/application/backup/index.blade.php b/resources/views/livewire/project/application/backup/index.blade.php index 7e89bbcd07..785b855cae 100644 --- a/resources/views/livewire/project/application/backup/index.blade.php +++ b/resources/views/livewire/project/application/backup/index.blade.php @@ -181,6 +181,7 @@ @foreach ($backups as $backup) @php $latestExecution = $backup->latestExecution; + $s3StorageNames = ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->pluck('name'); $status = $latestExecution?->status; $statusLabel = match ($status) { 'running' => 'In progress', @@ -208,9 +209,9 @@ {{ $backup->targetType() }} {{ $backup->frequency }} - - + + {{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }} diff --git a/resources/views/livewire/project/database/backup-edit/s3.blade.php b/resources/views/livewire/project/database/backup-edit/s3.blade.php index dcfb7c3815..4f0f7b43fd 100644 --- a/resources/views/livewire/project/database/backup-edit/s3.blade.php +++ b/resources/views/livewire/project/database/backup-edit/s3.blade.php @@ -25,11 +25,12 @@ @endif
    - +
    - @if ($executionStatus === 'running') - - @endif + :type="$executionStatusType" :pulse="$executionStatus === 'running'" />
    {{ data_get($execution, 'database_name', 'N/A') }} @@ -112,7 +112,22 @@ - @if (data_get($execution, 's3_uploaded') !== null) + @foreach ($s3Replicas as $replica) + + @endforeach + @if ($s3Replicas->isEmpty() && data_get($execution, 's3_uploaded') !== null) name)).includes(search.toLowerCase()) || @js(strtolower($backup->frequency)).includes(search.toLowerCase()) - || @js(strtolower($backup->s3?->name ?? '')).includes(search.toLowerCase())" + || @js(strtolower($s3StorageNames)).includes(search.toLowerCase())" class="data-table-row scheduled-backups-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.06]">
    @@ -96,8 +97,8 @@ @endif
    -
    - {{ $backup->save_s3 ? ($backup->s3?->name ?? 'Unavailable') : 'Local only' }} +
    + {{ $backup->save_s3 ? ($s3StorageNames ?: 'Unavailable') : 'Local only' }}
    'neutral', }; $databaseBackupId = 'database:'.$databaseBackup->id; - $databaseS3 = $databaseBackup->s3?->team_id === $serviceTeamId ? $databaseBackup->s3 : null; - $databaseS3Tooltip = ! $databaseBackup->save_s3 ? 'S3 storage: Not configured' : ($databaseS3 ? 'S3 storage: '.$databaseS3->name.' (bucket: '.$databaseS3->bucket.')' : 'S3 storage: Unavailable'); + $databaseS3s = ($databaseBackup->s3Storages->isNotEmpty() ? $databaseBackup->s3Storages : collect([$databaseBackup->s3])->filter())->where('team_id', $serviceTeamId); + $databaseS3Tooltip = ! $databaseBackup->save_s3 ? 'S3 storage: Not configured' : ($databaseS3s->isNotEmpty() ? 'S3 storage: '.$databaseS3s->map(fn ($s3) => $s3->name.' (bucket: '.$s3->bucket.')')->join(', ') : 'S3 storage: Unavailable'); @endphp
    {{ $databaseBackup->frequency }} - {{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }} @@ -308,8 +308,8 @@ @foreach ($backups as $backup) @php $latestExecution = $backup->latestExecution; - $volumeS3 = $backup->s3?->team_id === $serviceTeamId ? $backup->s3 : null; - $volumeS3Tooltip = ! $backup->save_s3 ? 'S3 storage: Not configured' : ($volumeS3 ? 'S3 storage: '.$volumeS3->name.' (bucket: '.$volumeS3->bucket.')' : 'S3 storage: Unavailable'); + $volumeS3s = ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->where('team_id', $serviceTeamId); + $volumeS3Tooltip = ! $backup->save_s3 ? 'S3 storage: Not configured' : ($volumeS3s->isNotEmpty() ? 'S3 storage: '.$volumeS3s->map(fn ($s3) => $s3->name.' (bucket: '.$s3->bucket.')')->join(', ') : 'S3 storage: Unavailable'); $status = $latestExecution?->status; $statusLabel = match ($status) { 'running' => 'In progress', @@ -338,8 +338,8 @@ {{ $backup->frequency }} - diff --git a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php index b4bf4465ec..041fb4470d 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php @@ -48,15 +48,19 @@ 'failed' => 'error', default => 'neutral', }; + $s3Replicas = $execution->s3Replicas; + $hasLiveS3Copies = $s3Replicas->isNotEmpty() + ? $s3Replicas->contains(fn ($replica) => $replica->s3_uploaded === true && ! $replica->s3_storage_deleted) + : $execution->s3_uploaded === true && ! $execution->s3_storage_deleted; $executionCheckboxes = []; $deleteActions = []; if (! $execution->local_storage_deleted) { $deleteActions[] = 'This backup will be permanently deleted from local storage.'; } - if ($execution->s3_uploaded === true && ! $execution->s3_storage_deleted) { + if ($hasLiveS3Copies) { $executionCheckboxes[] = [ 'id' => 'delete_backup_s3', - 'label' => 'Delete the selected backup permanently from S3 Storage', + 'label' => 'Delete the selected backup permanently from every S3 storage', ]; } if (empty($deleteActions)) { @@ -75,7 +79,7 @@
    - + @@ -98,7 +102,22 @@ - @if ($execution->s3_uploaded !== null) + @foreach ($s3Replicas as $replica) + + @endforeach + @if ($s3Replicas->isEmpty() && $execution->s3_uploaded !== null) - + Move
    - + :confirmWithPassword="false" step2ButtonText="Remove storage" + :actions="['Backups from this schedule will no longer be uploaded to this storage. S3 backup turns off when no other storage remains.']"> @@ -148,10 +150,10 @@
    - + :confirmWithPassword="false" step2ButtonText="Remove storage" + :actions="['Backups from this schedule will no longer be uploaded to this storage. S3 backup turns off when no other storage remains.']"> diff --git a/tests/Feature/Api/BackupS3DestinationsApiTest.php b/tests/Feature/Api/BackupS3DestinationsApiTest.php new file mode 100644 index 0000000000..5428dd51b7 --- /dev/null +++ b/tests/Feature/Api/BackupS3DestinationsApiTest.php @@ -0,0 +1,292 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $user = User::factory()->create(); + $this->team->members()->attach($user->id, ['role' => 'owner']); + + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 's3-destinations-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->headers = ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]; + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->database = StandalonePostgresql::create([ + 'name' => 'db', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'app', + 'image' => 'postgres:16', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->volume = LocalPersistentVolume::create([ + 'name' => 'data', + 'mount_path' => '/data', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + $this->firstS3 = createBackupDestinationS3($this->team, 'first'); + $this->secondS3 = createBackupDestinationS3($this->team, 'second'); + $this->otherTeamS3 = createBackupDestinationS3(Team::factory()->create(), 'foreign'); +}); + +function createBackupDestinationS3(Team $team, string $name): S3Storage +{ + return S3Storage::create([ + 'name' => $name, + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => $name.'-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); +} + +/** + * @return list + */ +function backupDestinationUuids(ScheduledDatabaseBackup|ScheduledVolumeBackup $backup): array +{ + return $backup->refresh()->selectedS3Storages()->pluck('uuid')->all(); +} + +describe('database backups', function () { + test('creates a backup with several destinations and lists them primary first', function () { + $response = $this->withHeaders($this->headers)->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_uuids' => [$this->firstS3->uuid, $this->secondS3->uuid], + 's3_storage_uuid' => $this->secondS3->uuid, + ])->assertCreated(); + + $backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->sole(); + expect($backup->s3_storage_id)->toBe($this->secondS3->id) + ->and(backupDestinationUuids($backup))->toBe([$this->secondS3->uuid, $this->firstS3->uuid]); + + $this->withHeaders($this->headers)->getJson("/api/v1/databases/{$this->database->uuid}/backups") + ->assertOk() + ->assertJsonPath('0.s3_storage_uuid', $this->secondS3->uuid) + ->assertJsonPath('0.s3_storage_uuids', [$this->secondS3->uuid, $this->firstS3->uuid]) + ->assertJsonMissingPath('0.s3_storages') + ->assertJsonMissingPath('0.s3'); + }); + + test('uses the first listed destination as primary when s3_storage_uuid is not sent', function () { + $response = $this->withHeaders($this->headers)->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_uuids' => [$this->secondS3->uuid, $this->firstS3->uuid], + ])->assertCreated(); + + expect(ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->sole()->s3_storage_id)->toBe($this->secondS3->id); + }); + + test('a single s3_storage_uuid that is already a destination becomes primary and keeps the others', function () { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => 'daily', + 'save_s3' => true, + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, + ]); + $backup->syncS3Storages([$this->firstS3->id, $this->secondS3->id]); + + $this->withHeaders($this->headers)->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [ + 'save_s3' => true, + 's3_storage_uuid' => $this->secondS3->uuid, + ])->assertOk(); + + expect(backupDestinationUuids($backup))->toBe([$this->secondS3->uuid, $this->firstS3->uuid]) + ->and($backup->s3_storage_id)->toBe($this->secondS3->id); + }); + + test('a single new s3_storage_uuid replaces every destination', function () { + $thirdS3 = createBackupDestinationS3($this->team, 'third'); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => 'daily', + 'save_s3' => true, + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, + ]); + $backup->syncS3Storages([$this->firstS3->id, $this->secondS3->id]); + + $this->withHeaders($this->headers)->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [ + 's3_storage_uuid' => $thirdS3->uuid, + ])->assertOk(); + + expect(backupDestinationUuids($backup))->toBe([$thirdS3->uuid]) + ->and($backup->s3_storage_id)->toBe($thirdS3->id); + }); + + test('updates the destination list', function () { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => 'daily', + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, + ]); + + $this->withHeaders($this->headers)->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [ + 'save_s3' => true, + 's3_storage_uuids' => [$this->firstS3->uuid, $this->secondS3->uuid], + ])->assertOk(); + + expect(backupDestinationUuids($backup))->toBe([$this->firstS3->uuid, $this->secondS3->uuid]) + ->and((bool) $backup->save_s3)->toBeTrue(); + }); + + test('rejects an S3 storage of another team without changing the backup', function () { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_id' => $this->firstS3->id, + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, + ]); + $backup->syncS3Storages([$this->firstS3->id]); + + $this->withHeaders($this->headers)->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [ + 's3_storage_uuids' => [$this->firstS3->uuid, $this->otherTeamS3->uuid], + ])->assertUnprocessable() + ->assertJsonValidationErrors(['s3_storage_uuids']) + ->assertDontSee($this->otherTeamS3->name); + + expect(backupDestinationUuids($backup))->toBe([$this->firstS3->uuid]); + }); + + test('rejects invalid destination combinations', function (array $payload, string $errorField) { + $payload = array_map(fn ($value) => is_array($value) + ? array_map(fn (string $name) => $this->{$name}->uuid, $value) + : (is_string($value) && property_exists($this, $value) ? $this->{$value}->uuid : $value), $payload); + + $this->withHeaders($this->headers)->postJson("/api/v1/databases/{$this->database->uuid}/backups", ['frequency' => 'daily', ...$payload]) + ->assertUnprocessable() + ->assertJsonValidationErrors([$errorField]); + + expect(ScheduledDatabaseBackup::count())->toBe(0); + })->with([ + 'primary outside the list' => [['s3_storage_uuids' => ['firstS3'], 's3_storage_uuid' => 'secondS3'], 's3_storage_uuid'], + 'save_s3 without destinations' => [['save_s3' => true, 's3_storage_uuids' => []], 's3_storage_uuid'], + 'foreign storage in the list' => [['save_s3' => true, 's3_storage_uuids' => ['firstS3', 'otherTeamS3']], 's3_storage_uuids'], + ]); + + test('rejects clearing the destinations of a backup that saves to S3', function () { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_id' => $this->firstS3->id, + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, + ]); + + $this->withHeaders($this->headers)->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [ + 's3_storage_uuids' => [], + ])->assertUnprocessable(); + + expect($backup->refresh()->s3_storage_id)->toBe($this->firstS3->id); + }); +}); + +describe('volume backups', function () { + test('sets several destinations and returns them primary first', function () { + $this->withHeaders($this->headers) + ->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_uuids' => [$this->firstS3->uuid, $this->secondS3->uuid], + 's3_storage_uuid' => $this->secondS3->uuid, + ]) + ->assertCreated() + ->assertJsonPath('s3_storage_uuid', $this->secondS3->uuid) + ->assertJsonPath('s3_storage_uuids', [$this->secondS3->uuid, $this->firstS3->uuid]); + + expect(backupDestinationUuids(ScheduledVolumeBackup::sole()))->toBe([$this->secondS3->uuid, $this->firstS3->uuid]); + }); + + test('a single s3_storage_uuid replaces every destination', function () { + $url = "/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups"; + $this->withHeaders($this->headers)->putJson($url, [ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_uuids' => [$this->firstS3->uuid, $this->secondS3->uuid], + ])->assertCreated(); + + $this->withHeaders($this->headers)->putJson($url, [ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_uuid' => $this->secondS3->uuid, + ])->assertOk()->assertJsonPath('s3_storage_uuids', [$this->secondS3->uuid]); + + expect(backupDestinationUuids(ScheduledVolumeBackup::sole()))->toBe([$this->secondS3->uuid]); + }); + + test('rejects an S3 storage of another team', function () { + $this->withHeaders($this->headers) + ->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_uuids' => [$this->firstS3->uuid, $this->otherTeamS3->uuid], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['s3_storage_uuids']) + ->assertDontSee($this->otherTeamS3->name); + + expect(ScheduledVolumeBackup::count())->toBe(0); + }); + + test('rejects a primary destination outside the list', function () { + $this->withHeaders($this->headers) + ->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_uuids' => [$this->firstS3->uuid], + 's3_storage_uuid' => $this->secondS3->uuid, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['s3_storage_uuid']); + }); +}); diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php index 66643ffc46..3479e8db11 100644 --- a/tests/Feature/BackupEditValidationTest.php +++ b/tests/Feature/BackupEditValidationTest.php @@ -424,7 +424,7 @@ it('defaults to the first available storage when multiple storages are available ]); Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) - ->assertSet('s3StorageId', $firstS3->id) + ->assertSet('s3StorageIds', [$firstS3->id]) ->set('saveS3', true) ->call('instantSave') ->assertDispatched('success'); @@ -461,7 +461,7 @@ it('accepts a root team S3 storage for the instance database backup', function ( Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => collect([$s3])]) ->set('saveS3', true) - ->set('s3StorageId', $s3->id) + ->set('s3StorageIds', [$s3->id]) ->call('instantSave') ->assertDispatched('success'); @@ -548,7 +548,7 @@ it('saves selected S3 storage immediately when it changes', function () { ]); Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) - ->set('s3StorageId', $secondS3->id) + ->set('s3StorageIds', [$secondS3->id]) ->assertDispatched('success'); $backup->refresh(); diff --git a/tests/Feature/BackupRetentionAndStaleDetectionTest.php b/tests/Feature/BackupRetentionAndStaleDetectionTest.php index 97e45599ff..8e235a6501 100644 --- a/tests/Feature/BackupRetentionAndStaleDetectionTest.php +++ b/tests/Feature/BackupRetentionAndStaleDetectionTest.php @@ -276,6 +276,8 @@ test('database S3 retention remains best effort when deletion fails', function ( ]); ScheduledDatabaseBackupExecution::whereKey($newestExecution->id)->update(['created_at' => now()]); ScheduledDatabaseBackupExecution::whereKey($oldExecution->id)->update(['created_at' => now()->subDay()]); + $newestExecution->s3Replicas()->create(['s3_storage_id' => $s3->id, 's3_uploaded' => true]); + $oldExecution->s3Replicas()->create(['s3_storage_id' => $s3->id, 's3_uploaded' => true]); $disk = Mockery::mock(); $disk->shouldReceive('delete')->once()->with(['/backup/old.dmp'])->andReturnFalse(); Storage::shouldReceive('build')->once()->andReturn($disk); diff --git a/tests/Feature/BackupS3DestinationsFormTest.php b/tests/Feature/BackupS3DestinationsFormTest.php new file mode 100644 index 0000000000..211360408a --- /dev/null +++ b/tests/Feature/BackupS3DestinationsFormTest.php @@ -0,0 +1,322 @@ + $name, + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => str($name)->slug()->toString(), + 'endpoint' => 'https://s3.example.com', + 'is_usable' => true, + 'team_id' => $team->id, + ]); +} + +/** + * @return array{0: Application, 1: LocalPersistentVolume, 2: StandalonePostgresql} + */ +function createResourcesForDestinationsFormTest(Team $team): array +{ + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + $database = StandalonePostgresql::create([ + 'name' => 'pg-destinations', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + return [$application, $volume, $database]; +} + +function createDatabaseBackupForDestinationsFormTest(StandalonePostgresql $database, array $overrides = []): ScheduledDatabaseBackup +{ + return ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => false, + 'database_type' => $database->getMorphClass(), + 'database_id' => $database->id, + 'team_id' => $database->team()->id, + ...$overrides, + ]); +} + +beforeEach(function () { + Server::flushIdentityMap(); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->otherTeam = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'admin']); + $this->user->teams()->attach($this->otherTeam, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + [$this->application, $this->volume, $this->database] = createResourcesForDestinationsFormTest($this->team); + $this->firstStorage = createS3StorageForDestinationsFormTest($this->team, 'First S3'); + $this->secondStorage = createS3StorageForDestinationsFormTest($this->team, 'Second S3'); + $this->foreignStorage = createS3StorageForDestinationsFormTest($this->otherTeam, 'Foreign S3'); +}); + +it('saves two S3 destinations for a database backup schedule', function () { + $backup = createDatabaseBackupForDestinationsFormTest($this->database); + + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + ->assertSee('First S3') + ->assertSee('Second S3') + ->assertSet('s3StorageIds', [$this->firstStorage->id]) + ->set('saveS3', true) + ->set('s3StorageIds', [(string) $this->firstStorage->id, (string) $this->secondStorage->id]) + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $backup->refresh(); + expect($backup->save_s3)->toBeTruthy() + ->and($backup->s3_storage_id)->toBe($this->firstStorage->id) + ->and($backup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all()) + ->toBe([$this->firstStorage->id, $this->secondStorage->id]); +}); + +it('saves two S3 destinations for a volume backup schedule', function () { + Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application]) + ->set('frequency', 'daily') + ->set('saveToS3', true) + ->set('s3StorageIds', [(string) $this->secondStorage->id, (string) $this->firstStorage->id]) + ->assertHasNoErrors() + ->assertDispatched('success'); + + $backup = ScheduledVolumeBackup::query()->sole(); + expect($backup->save_s3)->toBeTrue() + ->and($backup->s3_storage_id)->toBe($this->secondStorage->id) + ->and($backup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all()) + ->toBe([$this->firstStorage->id, $this->secondStorage->id]); +}); + +it('drops S3 storages of another team after a team switch', function () { + $backup = createDatabaseBackupForDestinationsFormTest($this->database, [ + 'save_s3' => true, + 's3_storage_id' => $this->firstStorage->id, + ]); + $backup->syncS3Storages([$this->firstStorage->id]); + + $databaseComponent = Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]); + $volumeComponent = Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application]); + session(['currentTeam' => $this->otherTeam]); + + $databaseComponent->set('s3StorageIds', [$this->foreignStorage->id]) + ->assertHasErrors('s3StorageIds'); + expect($backup->fresh()->s3Storages()->pluck('s3_storages.id')->all())->toBe([$this->firstStorage->id]); + + $databaseComponent->set('s3StorageIds', [$this->secondStorage->id, $this->foreignStorage->id]) + ->assertHasNoErrors(); + expect($backup->fresh()->s3_storage_id)->toBe($this->secondStorage->id) + ->and($backup->fresh()->s3Storages()->pluck('s3_storages.id')->all())->toBe([$this->secondStorage->id]); + + $volumeComponent->set('frequency', 'daily') + ->set('saveToS3', true) + ->set('s3StorageIds', [$this->foreignStorage->id]) + ->assertHasErrors('s3StorageIds'); + expect(ScheduledVolumeBackup::query()->count())->toBe(0); + + $volumeComponent->set('s3StorageIds', [$this->firstStorage->id, $this->foreignStorage->id]) + ->assertHasNoErrors(); + expect(ScheduledVolumeBackup::query()->sole()->s3Storages()->pluck('s3_storages.id')->all()) + ->toBe([$this->firstStorage->id]); +}); + +it('rejects S3 backups without a destination', function () { + $backup = createDatabaseBackupForDestinationsFormTest($this->database, [ + 'save_s3' => true, + 's3_storage_id' => $this->firstStorage->id, + ]); + $backup->syncS3Storages([$this->firstStorage->id]); + + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) + ->set('s3StorageIds', []) + ->call('submit') + ->assertHasErrors('s3StorageIds') + ->assertDispatched('error'); + + expect($backup->fresh()->save_s3)->toBeTruthy() + ->and($backup->fresh()->s3Storages()->pluck('s3_storages.id')->all())->toBe([$this->firstStorage->id]); + + Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application]) + ->set('frequency', 'daily') + ->set('saveToS3', true) + ->set('s3StorageIds', []) + ->call('save') + ->assertHasErrors('s3StorageIds'); + + expect(ScheduledVolumeBackup::query()->count())->toBe(0); +}); + +it('moves and removes one destination of a multi-destination schedule from the storage page', function () { + $thirdStorage = createS3StorageForDestinationsFormTest($this->team, 'Third S3'); + $databaseBackup = createDatabaseBackupForDestinationsFormTest($this->database, ['save_s3' => true]); + $databaseBackup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); + $volumeBackup = ScheduledVolumeBackup::create([ + 'backupable_type' => $this->volume->getMorphClass(), + 'backupable_id' => $this->volume->id, + 'team_id' => $this->team->id, + 'frequency' => 'daily', + 'enabled' => true, + 'save_s3' => true, + ]); + $volumeBackup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); + + Livewire::test(StorageResources::class, ['storage' => $this->secondStorage]) + ->assertSet("selectedStorages.{$databaseBackup->id}", $this->secondStorage->id) + ->assertSet("selectedVolumeStorages.{$volumeBackup->id}", $this->secondStorage->id) + ->assertSee('pg-destinations') + ->assertSee('app-data'); + + Livewire::test(StorageResources::class, ['storage' => $this->firstStorage]) + ->set("selectedStorages.{$databaseBackup->id}", $thirdStorage->id) + ->call('moveBackup', $databaseBackup->id) + ->set("selectedVolumeStorages.{$volumeBackup->id}", $thirdStorage->id) + ->call('moveVolumeBackup', $volumeBackup->id); + + foreach ([$databaseBackup, $volumeBackup] as $backup) { + $backup->refresh(); + expect($backup->s3_storage_id)->toBe($thirdStorage->id) + ->and($backup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all()) + ->toBe([$this->secondStorage->id, $thirdStorage->id]); + } + + Livewire::test(StorageResources::class, ['storage' => $this->secondStorage]) + ->call('disableS3', $databaseBackup->id) + ->call('disableVolumeS3', $volumeBackup->id); + + foreach ([$databaseBackup, $volumeBackup] as $backup) { + $backup->refresh(); + expect($backup->save_s3)->toBeTruthy() + ->and($backup->s3_storage_id)->toBe($thirdStorage->id) + ->and($backup->s3Storages()->pluck('s3_storages.id')->all())->toBe([$thirdStorage->id]); + } + + Livewire::test(StorageResources::class, ['storage' => $thirdStorage]) + ->call('disableS3', $databaseBackup->id) + ->call('disableVolumeS3', $volumeBackup->id); + + foreach ([$databaseBackup, $volumeBackup] as $backup) { + $backup->refresh(); + expect($backup->save_s3)->toBeFalsy() + ->and($backup->s3_storage_id)->toBeNull() + ->and($backup->s3Storages()->count())->toBe(0); + } +}); + +it('shows the copy status of each destination in the execution lists', function () { + $databaseBackup = createDatabaseBackupForDestinationsFormTest($this->database, ['save_s3' => true]); + $databaseBackup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); + $databaseExecution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $databaseBackup->id, + 'status' => 'success', + 'filename' => '/backups/dump.dmp', + 's3_uploaded' => false, + ]); + $volumeBackup = ScheduledVolumeBackup::create([ + 'backupable_type' => $this->volume->getMorphClass(), + 'backupable_id' => $this->volume->id, + 'team_id' => $this->team->id, + 'frequency' => 'daily', + 'save_s3' => true, + ]); + $volumeBackup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); + $volumeExecution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $volumeBackup->id, + 'status' => 'success', + 'filename' => '/backups/volume.tar.gz', + 's3_uploaded' => false, + ]); + foreach ([$databaseExecution, $volumeExecution] as $execution) { + $execution->s3Replicas()->create(['s3_storage_id' => $this->firstStorage->id, 's3_uploaded' => true]); + $execution->s3Replicas()->create(['s3_storage_id' => $this->secondStorage->id, 's3_uploaded' => false, 'message' => 'Access denied']); + } + + Livewire::test(BackupExecutions::class, ['backup' => $databaseBackup]) + ->assertSeeInOrder(['First S3 Available', 'Second S3 Failed']) + ->assertSee('Access denied') + ->assertSee('Delete the selected backup permanently from every S3 storage'); + + Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application, 'section' => 'executions']) + ->assertSeeInOrder(['First S3', 'Second S3 failed']) + ->assertSee('Delete the selected backup permanently from every S3 storage'); +}); + +it('keeps a selected destination that is unusable for a while when other settings are saved', function () { + $databaseBackup = createDatabaseBackupForDestinationsFormTest($this->database, ['save_s3' => true]); + $databaseBackup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); + $volumeBackup = ScheduledVolumeBackup::create([ + 'backupable_type' => $this->volume->getMorphClass(), + 'backupable_id' => $this->volume->id, + 'team_id' => $this->team->id, + 'frequency' => '0 0 * * *', + 'save_s3' => true, + ]); + $volumeBackup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); + $this->secondStorage->update(['is_usable' => false]); + + Livewire::test(BackupEdit::class, ['backup' => $databaseBackup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + ->assertSee('Second S3 (unavailable)') + ->set('timeout', 7200) + ->call('submit') + ->assertHasNoErrors(); + Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application, 'section' => 's3']) + ->assertSee('Second S3 (unavailable)') + ->set('stopDuringBackup', true) + ->call('save') + ->assertHasNoErrors(); + + expect($databaseBackup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all()) + ->toBe([$this->firstStorage->id, $this->secondStorage->id]) + ->and($volumeBackup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all()) + ->toBe([$this->firstStorage->id, $this->secondStorage->id]); +}); diff --git a/tests/Feature/DatabaseBackupMultipleS3DestinationsTest.php b/tests/Feature/DatabaseBackupMultipleS3DestinationsTest.php new file mode 100644 index 0000000000..0191aa09d7 --- /dev/null +++ b/tests/Feature/DatabaseBackupMultipleS3DestinationsTest.php @@ -0,0 +1,434 @@ + $name, + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => strtolower($name).'-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + ]); +} + +function multiS3Execution(ScheduledDatabaseBackup $backup, string $name, array $attributes = []): ScheduledDatabaseBackupExecution +{ + $execution = ScheduledDatabaseBackupExecution::create([ + 'uuid' => $name, + 'database_name' => 'app', + 'filename' => "/backup/{$name}.dmp", + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'success', + 's3_uploaded' => true, + 'local_storage_deleted' => true, + ...$attributes, + ]); + + return $execution; +} + +/** + * Records the files that deleteBackupsS3() removes, keyed by bucket. + */ +function fakeMultiS3Deletes(): Collection +{ + $deleted = collect(); + Storage::shouldReceive('build')->andReturnUsing(function (array $config) use ($deleted) { + $disk = Mockery::mock(); + $disk->shouldReceive('delete')->andReturnUsing(function (array $files) use ($config, $deleted) { + $deleted->put($config['bucket'], [...$deleted->get($config['bucket'], []), ...$files]); + + return true; + }); + + return $disk; + }); + + return $deleted; +} + +/** + * Runs a backup with two S3 destinations, failing the upload to the storages named in $failingStorages. + * + * @return array{0: ScheduledDatabaseBackupExecution, 1: Collection} + */ +function runMultiS3BackupJob(array $failingStorages): array +{ + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + $database = create_standalone_postgresql($environment->id, $destination, ['status' => 'running:healthy']); + $first = multiS3Storage($team, 'First'); + $second = multiS3Storage($team, 'Second'); + $backup = new ScheduledDatabaseBackup; + $backup->forceFill([ + 'enabled' => true, + 'save_s3' => true, + 'disable_local_backup' => true, + 's3_storage_id' => $first->id, + 'frequency' => 'daily', + 'databases_to_backup' => 'app', + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + 'team_id' => $team->id, + ])->save(); + $backup->syncS3Storages([$first->id, $second->id]); + $commands = collect(); + Process::fake(function ($process) use ($commands) { + $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $commands->push($command); + + return Process::result(output: str_contains($command, 'du -b') ? '128' : ''); + }); + + $job = Mockery::mock(DatabaseBackupJob::class.'[upload_to_s3]', [$backup->fresh()])->shouldAllowMockingProtectedMethods(); + $job->shouldReceive('upload_to_s3')->andReturnUsing(function (S3Storage $storage) use ($failingStorages) { + if (in_array($storage->name, $failingStorages, true)) { + throw new RuntimeException('access denied'); + } + }); + $job->handle(); + + return [ScheduledDatabaseBackupExecution::query()->sole(), $commands]; +} + +it('applies S3 retention to each destination separately', function () { + $team = Team::factory()->create(); + $first = multiS3Storage($team, 'First'); + $second = multiS3Storage($team, 'Second'); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 'disable_local_backup' => true, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + 'database_backup_retention_amount_s3' => 1, + ]); + $backup->syncS3Storages([$first->id, $second->id]); + $old = multiS3Execution($backup, 'old'); + $new = multiS3Execution($backup, 'new', ['s3_uploaded' => false]); + ScheduledDatabaseBackupExecution::whereKey($old->id)->update(['created_at' => now()->subDay()]); + $old->s3Replicas()->create(['s3_storage_id' => $first->id, 's3_uploaded' => true]); + $old->s3Replicas()->create(['s3_storage_id' => $second->id, 's3_uploaded' => true]); + $new->s3Replicas()->create(['s3_storage_id' => $first->id, 's3_uploaded' => true]); + $new->s3Replicas()->create(['s3_storage_id' => $second->id, 's3_uploaded' => false, 'message' => 'denied']); + $deleted = fakeMultiS3Deletes(); + + removeOldBackups($backup); + + expect($deleted->all())->toBe(['first-bucket' => ['/backup/old.dmp']]) + ->and($old->s3Replicas()->where('s3_storage_id', $first->id)->sole()->s3_storage_deleted)->toBeTrue() + ->and($old->s3Replicas()->where('s3_storage_id', $second->id)->sole()->s3_storage_deleted)->toBeFalse() + ->and($old->fresh()->s3_storage_deleted)->toBeFalse() + ->and($backup->executions()->count())->toBe(2); +}); + +it('deletes the execution record when its last S3 copy expires', function () { + $team = Team::factory()->create(); + $first = multiS3Storage($team, 'First'); + $second = multiS3Storage($team, 'Second'); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 'disable_local_backup' => true, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + 'database_backup_retention_amount_s3' => 1, + ]); + $backup->syncS3Storages([$first->id, $second->id]); + $old = multiS3Execution($backup, 'old'); + $new = multiS3Execution($backup, 'new'); + ScheduledDatabaseBackupExecution::whereKey($old->id)->update(['created_at' => now()->subDay()]); + foreach ([$old, $new] as $execution) { + $execution->s3Replicas()->create(['s3_storage_id' => $first->id, 's3_uploaded' => true]); + $execution->s3Replicas()->create(['s3_storage_id' => $second->id, 's3_uploaded' => true]); + } + $deleted = fakeMultiS3Deletes(); + + removeOldBackups($backup); + + expect($deleted->all())->toBe(['first-bucket' => ['/backup/old.dmp'], 'second-bucket' => ['/backup/old.dmp']]) + ->and($old->fresh())->toBeNull() + ->and(DatabaseBackupS3Replica::where('execution_id', $old->id)->exists())->toBeFalse() + ->and($new->fresh())->not->toBeNull(); +}); + +it('marks copies in a deleted storage as gone without calling S3', function () { + $team = Team::factory()->create(); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 'disable_local_backup' => true, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + $execution = multiS3Execution($backup, 'orphaned'); + $execution->s3Replicas()->create(['s3_storage_id' => null, 's3_uploaded' => true]); + Storage::shouldReceive('build')->never(); + + removeOldBackups($backup); + + expect($execution->fresh())->toBeNull(); +}); + +it('keeps an execution whose local file still exists after its S3 copies expire', function () { + $team = Team::factory()->create(); + $storage = multiS3Storage($team, 'First'); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + $execution = multiS3Execution($backup, 'local-copy', ['local_storage_deleted' => false]); + $execution->s3Replicas()->create(['s3_storage_id' => $storage->id, 's3_uploaded' => true, 's3_storage_deleted' => true]); + + removeOldBackups($backup); + + expect($execution->fresh())->not->toBeNull(); +}); + +it('deletes the S3 copies of an execution from every destination that has one', function () { + $team = Team::factory()->create(); + $first = multiS3Storage($team, 'First'); + $second = multiS3Storage($team, 'Second'); + $failed = multiS3Storage($team, 'Failed'); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + $execution = multiS3Execution($backup, 'manual', ['s3_uploaded' => false]); + $execution->s3Replicas()->create(['s3_storage_id' => $first->id, 's3_uploaded' => true]); + $execution->s3Replicas()->create(['s3_storage_id' => $second->id, 's3_uploaded' => true]); + $execution->s3Replicas()->create(['s3_storage_id' => $failed->id, 's3_uploaded' => false]); + $deleted = fakeMultiS3Deletes(); + + $execution->deleteS3Copies(); + + expect($deleted->keys()->sort()->values()->all())->toBe(['first-bucket', 'second-bucket']) + ->and($execution->fresh()->s3_storage_deleted)->toBeTrue() + ->and($execution->hasLiveS3Copies())->toBeFalse(); +}); + +describe('backup job', function () { + beforeEach(function () { + Storage::fake('ssh-keys'); + Storage::fake('ssh-mux'); + Event::fake([BackupCreated::class]); + Notification::fake(); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + }); + + it('uploads to every destination and deletes the local file when all uploads succeed', function () { + [$execution, $commands] = runMultiS3BackupJob([]); + + expect($execution->status)->toBe('success') + ->and($execution->s3_uploaded)->toBeTrue() + ->and($execution->local_storage_deleted)->toBeTrue() + ->and($execution->s3Replicas()->pluck('s3_uploaded')->all())->toBe([true, true]) + ->and($commands->contains(fn (string $command) => str_contains($command, 'rm -f') && str_contains($command, $execution->filename)))->toBeTrue(); + }); + + it('continues to later destinations and keeps the local file when a replica write fails', function (array $failingStorages) { + $event = 'eloquent.creating: '.DatabaseBackupS3Replica::class; + Event::listen($event, function (DatabaseBackupS3Replica $replica): void { + if (S3Storage::find($replica->s3_storage_id)?->name === 'First') { + throw new RuntimeException('replica persistence failed'); + } + }); + + try { + [$execution, $commands] = runMultiS3BackupJob($failingStorages); + } finally { + Event::forget($event); + } + + $replica = $execution->s3Replicas()->with('s3')->sole(); + expect($replica->s3->name)->toBe('Second') + ->and($replica->s3_uploaded)->toBeTrue() + ->and($execution->s3_storage_deleted)->toBeFalse() + ->and($execution->s3_uploaded)->toBeFalse() + ->and($execution->local_storage_deleted)->toBeFalse() + ->and($execution->message)->toContain('First: replica persistence failed') + ->and($commands->contains(fn (string $command) => str_contains($command, 'rm -f') && str_contains($command, $execution->filename)))->toBeFalse(); + + if ($failingStorages !== []) { + expect($execution->message)->toContain('First: access denied'); + } + })->with([ + 'successful upload' => [[]], + 'failed upload' => [['First']], + ]); + + it('keeps the local file and names the failed destination when one upload fails', function () { + [$execution, $commands] = runMultiS3BackupJob(['Second']); + + $replicas = $execution->s3Replicas()->with('s3')->get()->keyBy(fn ($replica) => $replica->s3->name); + expect($execution->status)->toBe('success') + ->and($execution->s3_uploaded)->toBeFalse() + ->and($execution->local_storage_deleted)->toBeFalse() + ->and($execution->message)->toContain('Second: access denied') + ->and($replicas['First']->s3_uploaded)->toBeTrue() + ->and($replicas['Second']->s3_uploaded)->toBeFalse() + ->and($replicas['Second']->message)->toBe('access denied') + ->and($commands->contains(fn (string $command) => str_contains($command, 'rm -f') && str_contains($command, $execution->filename)))->toBeFalse(); + }); +}); + +it('deletes an execution from every S3 destination through the API', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + $sshKeys = Storage::fake('ssh-keys'); + $sshMux = Storage::fake('ssh-mux'); + Process::fake(); + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user, ['role' => 'owner']); + session(['currentTeam' => $team]); + $token = $user->createToken('test-token', ['*'])->plainTextToken; + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + $database = create_standalone_postgresql($environment->id, $destination); + $first = multiS3Storage($team, 'First'); + $second = multiS3Storage($team, 'Second'); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 'database_type' => $database->getMorphClass(), + 'database_id' => $database->id, + 'team_id' => $team->id, + ]); + $backup->syncS3Storages([$first->id, $second->id]); + $execution = multiS3Execution($backup, 'api', ['local_storage_deleted' => false]); + $execution->s3Replicas()->create(['s3_storage_id' => $first->id, 's3_uploaded' => true]); + $execution->s3Replicas()->create(['s3_storage_id' => $second->id, 's3_uploaded' => true]); + $deleted = fakeMultiS3Deletes(); + Storage::shouldReceive('disk')->with('ssh-keys')->andReturn($sshKeys); + Storage::shouldReceive('disk')->with('ssh-mux')->andReturn($sshMux); + + $this->withHeaders(['Authorization' => 'Bearer '.$token]) + ->deleteJson("/api/v1/databases/{$database->uuid}/backups/{$backup->uuid}/executions/{$execution->uuid}?delete_s3=true") + ->assertOk(); + + expect($deleted->all())->toBe(['first-bucket' => ['/backup/api.dmp'], 'second-bucket' => ['/backup/api.dmp']]) + ->and($execution->fresh())->toBeNull(); +}); + +it('copies the destinations of the same team to a cloned schedule', function () { + $team = Team::factory()->create(); + $otherTeam = Team::factory()->create(); + $first = multiS3Storage($team, 'First'); + $second = multiS3Storage($team, 'Second'); + $foreign = multiS3Storage($otherTeam, 'Foreign'); + $attributes = [ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]; + $backup = ScheduledDatabaseBackup::create($attributes); + $backup->syncS3Storages([$first->id, $second->id]); + $backup->s3Storages()->attach($foreign->id); + $copy = ScheduledDatabaseBackup::create($attributes); + + $backup->copyS3StoragesTo($copy); + + expect($copy->s3Storages()->pluck('s3_storages.id')->sort()->values()->all())->toBe([$first->id, $second->id]); +}); + +it('requires a live S3 copy for local retention only when local backups are disabled', function (bool $disabled, ?array $replica, bool $deleted) { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + $database = create_standalone_postgresql($environment->id, $destination); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 'disable_local_backup' => $disabled, + 'database_backup_retention_amount_locally' => 1, + 'database_type' => $database->getMorphClass(), + 'database_id' => $database->id, + 'team_id' => $team->id, + ]); + $older = multiS3Execution($backup, 'older', ['local_storage_deleted' => false, 'created_at' => now()->subDay()]); + $newer = multiS3Execution($backup, 'newer', ['local_storage_deleted' => false]); + if ($replica !== null) { + $storage = multiS3Storage($team, 'Retention'); + foreach ([$older, $newer] as $execution) { + $execution->s3Replicas()->create(['s3_storage_id' => $storage->id, ...$replica]); + } + } + $commands = collect(); + Process::fake(function ($process) use ($commands) { + $commands->push(is_array($process->command) ? implode(' ', $process->command) : $process->command); + + return Process::result(); + }); + + removeOldBackups($backup); + + expect($commands->contains(fn (string $command): bool => str_contains($command, '/backup/older.dmp')))->toBe($deleted); + if ($deleted && $replica === null) { + $this->assertModelMissing($older); + } else { + expect($older->fresh()->local_storage_deleted)->toBe($deleted); + } + expect($newer->fresh()->local_storage_deleted)->toBeFalse(); +})->with([ + 'no replica' => [true, null, false], + 'failed upload' => [true, ['s3_uploaded' => false], false], + 'deleted replica' => [true, ['s3_uploaded' => true, 's3_storage_deleted' => true], false], + 'live replica' => [true, ['s3_uploaded' => true, 's3_storage_deleted' => false], true], + 'normal local retention' => [false, null, true], +]); diff --git a/tests/Feature/DevelopmentQemuVmTest.php b/tests/Feature/DevelopmentQemuVmTest.php index ad6d1d76d0..864536cfa7 100644 --- a/tests/Feature/DevelopmentQemuVmTest.php +++ b/tests/Feature/DevelopmentQemuVmTest.php @@ -173,16 +173,41 @@ it('can change the localhost qemu profile without adding a server', function () it('seeds localhost through the container command', function () { $this->seed(ServerSeeder::class); + Process::fake(); expect(Artisan::call('dev:qemu:seed', [ 'profile' => 'ubuntu-root', '--as-localhost' => true, ]))->toBe(Command::SUCCESS) ->and(Server::query()->findOrFail(0)->ip)->toBe('192.168.122.10'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker run -d --name coolify-db') + && str_contains($process->command, 'POSTGRES_DB=')); +}); + +it('starts the instance database container with sudo on a non-root localhost vm', function () { + $this->seed(ServerSeeder::class); + Process::fake(); + + Artisan::call('dev:qemu:seed', ['profile' => 'ubuntu-non-root', '--as-localhost' => true]); + + Process::assertRan(fn ($process) => str_contains($process->command, 'if ! sudo docker inspect coolify-db') + && str_contains($process->command, 'sudo docker run -d --name coolify-db') + && str_contains($process->command, 'sudo docker start coolify-db')); +}); + +it('still seeds localhost when the instance database container cannot start', function () { + $this->seed(ServerSeeder::class); + Process::fake(fn () => Process::result(errorOutput: 'Cannot connect to the Docker daemon', exitCode: 1)); + + expect(Artisan::call('dev:qemu:seed', ['profile' => 'ubuntu-root', '--as-localhost' => true]))->toBe(Command::SUCCESS) + ->and(Artisan::output())->toContain('Could not start the coolify-db container') + ->and(Server::query()->findOrFail(0)->ip)->toBe('192.168.122.10'); }); it('seeds localhost with the forwarded ssh address of a lima vm', function () { $this->seed(ServerSeeder::class); + Process::fake(); expect(Artisan::call('dev:qemu:seed', [ 'profile' => 'debian-non-root', diff --git a/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php b/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php new file mode 100644 index 0000000000..2cbac04a38 --- /dev/null +++ b/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php @@ -0,0 +1,137 @@ + $name, + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => str($name)->slug()->toString(), + 'endpoint' => 'https://s3.example.com', + 'is_usable' => true, + 'team_id' => $team->id, + ]); +} + +beforeEach(function () { + Server::flushIdentityMap(); + $this->rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + $server = Server::factory()->create([ + 'id' => 0, + 'team_id' => 0, + 'ip' => '127.0.0.1', + 'private_key_id' => PrivateKey::factory()->create(['team_id' => 0])->id, + ]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail()->forceFill(['id' => 0])->save(); + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); + + $this->database = new StandalonePostgresql; + $this->database->forceFill([ + 'id' => 0, + 'name' => 'coolify-db', + 'postgres_user' => 'coolify', + 'postgres_password' => 'password', + 'postgres_db' => 'coolify', + 'status' => 'running', + 'destination_type' => StandaloneDocker::class, + 'destination_id' => 0, + ])->save(); + $this->backup = ScheduledDatabaseBackup::create([ + 'enabled' => true, + 'save_s3' => false, + 'frequency' => '0 0 * * *', + 'database_id' => 0, + 'database_type' => StandalonePostgresql::class, + 'team_id' => 0, + ]); + + $this->firstStorage = instanceBackupS3Storage($this->rootTeam, 'First S3'); + $this->secondStorage = instanceBackupS3Storage($this->rootTeam, 'Second S3'); + $this->otherTeam = Team::factory()->create(); + $this->foreignStorage = instanceBackupS3Storage($this->otherTeam, 'Foreign S3'); + + $user = User::factory()->create(); + $this->rootTeam->members()->attach($user->id, ['role' => 'admin']); + $user->teams()->attach($this->otherTeam->id, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => ['id' => 0]]); +}); + +it('offers every root team S3 storage on the instance backup page', function () { + Livewire::test(SettingsBackup::class) + ->assertOk() + ->assertSee('First S3') + ->assertSee('Second S3') + ->assertDontSee('Foreign S3'); +}); + +it('saves several S3 destinations for the instance backup and drops storages of other teams', function () { + // The session team can change in another tab after the page loads. + session(['currentTeam' => ['id' => $this->otherTeam->id]]); + + Livewire::test(BackupEdit::class, ['backup' => $this->backup->fresh(), 'availableS3Storages' => collect(), 'section' => 's3']) + ->set('saveS3', true) + ->set('s3StorageIds', [$this->firstStorage->id, $this->secondStorage->id, $this->foreignStorage->id]) + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $backup = $this->backup->fresh(); + + expect($backup->save_s3)->toBeTruthy() + ->and($backup->s3_storage_id)->toBe($this->firstStorage->id) + ->and($backup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all()) + ->toBe([$this->firstStorage->id, $this->secondStorage->id]); +}); + +it('uploads the instance backup to every selected destination', function () { + $this->backup->forceFill(['save_s3' => true, 'databases_to_backup' => 'coolify'])->save(); + $this->backup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); + Process::fake(fn ($process) => Process::result(output: str_contains(implode(' ', (array) $process->command), 'du -b') ? '128' : '')); + + $uploadedTo = collect(); + $job = Mockery::mock(DatabaseBackupJob::class.'[upload_to_s3]', [$this->backup->fresh()])->shouldAllowMockingProtectedMethods(); + $job->shouldReceive('upload_to_s3')->andReturnUsing(fn (S3Storage $storage) => $uploadedTo->push($storage->id)); + $job->handle(); + + $execution = ScheduledDatabaseBackupExecution::query()->sole(); + + expect($uploadedTo->sort()->values()->all())->toBe([$this->firstStorage->id, $this->secondStorage->id]) + ->and($execution->status)->toBe('success') + ->and($execution->s3_uploaded)->toBeTrue() + ->and($execution->s3Replicas()->where('s3_uploaded', true)->pluck('s3_storage_id')->sort()->values()->all()) + ->toBe([$this->firstStorage->id, $this->secondStorage->id]); +}); + +it('links the instance backup from the storage resources page', function () { + $this->backup->forceFill(['save_s3' => true])->save(); + $this->backup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); + + Livewire::test(StorageResources::class, ['storage' => $this->secondStorage]) + ->assertSee('coolify-db') + ->assertSeeHtml(route('settings.backup')); +}); diff --git a/tests/Feature/Jobs/DatabaseBackupJobTest.php b/tests/Feature/Jobs/DatabaseBackupJobTest.php index a19958608f..d0ba825511 100644 --- a/tests/Feature/Jobs/DatabaseBackupJobTest.php +++ b/tests/Feature/Jobs/DatabaseBackupJobTest.php @@ -52,7 +52,7 @@ test('scheduled database backup casts full dump selection to boolean', function ->and((new ScheduledDatabaseBackup(['dump_all' => '1']))->dump_all)->toBeTrue(); }); -test('upload_to_s3 throws exception and disables s3 when storage is null', function () { +test('s3 upload throws exception and disables s3 when no destination exists', function () { $backup = ScheduledDatabaseBackup::create([ 'frequency' => '0 0 * * *', 'save_s3' => true, @@ -64,11 +64,7 @@ test('upload_to_s3 throws exception and disables s3 when storage is null', funct $job = new DatabaseBackupJob($backup); - $reflection = new ReflectionClass($job); - $s3Property = $reflection->getProperty('s3'); - $s3Property->setValue($job, null); - - $method = $reflection->getMethod('upload_to_s3'); + $method = (new ReflectionClass($job))->getMethod('uploadToS3Destinations'); expect(fn () => $method->invoke($job)) ->toThrow(Exception::class, 'S3 storage configuration is missing or has been deleted'); @@ -78,7 +74,7 @@ test('upload_to_s3 throws exception and disables s3 when storage is null', funct expect($backup->s3_storage_id)->toBeNull(); }); -test('upload_to_s3 exception message reports the previous s3 storage id', function () { +test('s3 upload exception message reports the previous s3 storage id', function () { $backup = ScheduledDatabaseBackup::create([ 'frequency' => '0 0 * * *', 'save_s3' => true, @@ -90,10 +86,7 @@ test('upload_to_s3 exception message reports the previous s3 storage id', functi $job = new DatabaseBackupJob($backup); - $reflection = new ReflectionClass($job); - $reflection->getProperty('s3')->setValue($job, null); - - expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job)) + expect(fn () => (new ReflectionClass($job))->getMethod('uploadToS3Destinations')->invoke($job)) ->toThrow(Exception::class, 'S3 storage ID: 12345'); $backup->refresh(); @@ -101,7 +94,7 @@ test('upload_to_s3 exception message reports the previous s3 storage id', functi expect($backup->s3_storage_id)->toBeNull(); }); -test('upload_to_s3 exception message reports null when no previous s3 storage id exists', function () { +test('s3 upload exception message reports null when no previous s3 storage id exists', function () { $backup = ScheduledDatabaseBackup::create([ 'frequency' => '0 0 * * *', 'save_s3' => true, @@ -113,10 +106,7 @@ test('upload_to_s3 exception message reports null when no previous s3 storage id $job = new DatabaseBackupJob($backup); - $reflection = new ReflectionClass($job); - $reflection->getProperty('s3')->setValue($job, null); - - expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job)) + expect(fn () => (new ReflectionClass($job))->getMethod('uploadToS3Destinations')->invoke($job)) ->toThrow(Exception::class, 'S3 storage ID: null'); }); @@ -281,7 +271,7 @@ test('retention cleanup failure does not fail a successful database backup', fun 'local_storage_deleted' => true, 'created_at' => now()->subDay(), ]); - ScheduledDatabaseBackupExecution::create([ + $newExecution = ScheduledDatabaseBackupExecution::create([ 'uuid' => 'new-backup', 'database_name' => 'database', 'filename' => '/backup/new.dmp', @@ -290,6 +280,8 @@ test('retention cleanup failure does not fail a successful database backup', fun 's3_uploaded' => true, 'local_storage_deleted' => true, ]); + $oldExecution->s3Replicas()->create(['s3_storage_id' => $s3->id, 's3_uploaded' => true]); + $newExecution->s3Replicas()->create(['s3_storage_id' => $s3->id, 's3_uploaded' => true]); $disk = Mockery::mock(); $disk->shouldReceive('delete')->once()->with(['/backup/old.dmp'])->andReturnFalse(); Storage::shouldReceive('build')->once()->andReturn($disk); diff --git a/tests/Feature/Mcp/McpReadToolsTest.php b/tests/Feature/Mcp/McpReadToolsTest.php index 21df936c94..f45757b8d1 100644 --- a/tests/Feature/Mcp/McpReadToolsTest.php +++ b/tests/Feature/Mcp/McpReadToolsTest.php @@ -10,6 +10,7 @@ use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\InstanceSettings; use App\Models\Project; +use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackupExecution; use App\Models\ScheduledTask; @@ -217,6 +218,36 @@ test('database backup tools scope schedules by database type and id', function ( expect($response->json('result.isError'))->toBeTrue(); }); +test('list_database_backups returns every S3 destination primary first', function () { + $postgres = StandalonePostgresql::create([ + 'name' => 'multi-s3-db', + 'postgres_password' => 'password', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + [$first, $second] = collect(['first', 'second'])->map(fn (string $name) => S3Storage::create([ + 'name' => $name, 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', + 'bucket' => $name, 'endpoint' => 'https://s3.example.com', 'team_id' => $this->team->id, + ]))->all(); + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => $second->id, + 'database_id' => $postgres->id, + 'database_type' => $postgres->getMorphClass(), + ]); + $backup->syncS3Storages([$first->id, $second->id]); + + $response = mcpReadCall('list_database_backups', ['uuid' => $postgres->uuid]); + $response->assertOk(); + + $listed = mcpReadJson($response)['data']['backups'][0]; + expect($listed['s3_storage_uuid'])->toBe($second->uuid) + ->and($listed['s3_storage_uuids'])->toBe([$second->uuid, $first->uuid]); +}); + test('list_backup_executions omits messages without sensitive read and redacts when included', function () { $postgres = StandalonePostgresql::create([ 'name' => 'backup-msg-db', diff --git a/tests/Feature/MutableLivewireComponentsAuthorizationTest.php b/tests/Feature/MutableLivewireComponentsAuthorizationTest.php index 5563e87769..47b215d5f0 100644 --- a/tests/Feature/MutableLivewireComponentsAuthorizationTest.php +++ b/tests/Feature/MutableLivewireComponentsAuthorizationTest.php @@ -182,7 +182,7 @@ it('authorizes every volume backup form control', function (string $path, array [ '/]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*>\s*Enable S3\s*<\/x-forms\.button>/s', '/]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*>\s*Disable S3\s*<\/x-forms\.button>/s', - '/fresh()->s3_storage_id)->toBe($ownS3->id) ->and($backup->fresh()->save_s3)->toBeTruthy(); - $component->set('s3StorageId', $foreignS3->id)->call('instantSave'); + $component->set('s3StorageIds', [$foreignS3->id])->call('instantSave'); expect($backup->fresh()->s3_storage_id)->toBe($ownS3->id); }); diff --git a/tests/Feature/ScheduleAuditTest.php b/tests/Feature/ScheduleAuditTest.php index 281fdd42b4..6c700eae69 100644 --- a/tests/Feature/ScheduleAuditTest.php +++ b/tests/Feature/ScheduleAuditTest.php @@ -300,12 +300,12 @@ test('disabling or moving S3 storage for backups records audit events', function $volumeEvent = scheduleAuditEvent('ui.volume_backup.schedule_set'); expect($databaseEvent->resource_uuid)->toBe($database->uuid) - ->and($databaseEvent->metadata['changed_fields'])->toEqualCanonicalizing(['save_s3', 's3_storage_id']) + ->and($databaseEvent->metadata['changed_fields'])->toEqualCanonicalizing(['save_s3', 's3_storage_id', 's3_storages']) ->and($volumeEvent->metadata['resource_type'])->toBe('application') ->and($volumeEvent->metadata['resource_uuid'])->toBe($this->application->uuid) ->and($volumeEvent->metadata['storage_uuid'])->toBe($volume->uuid) ->and($volumeEvent->metadata['backup_uuid'])->toBe($volumeBackup->uuid) - ->and($volumeEvent->metadata['changed_fields'])->toBe(['s3_storage_id']); + ->and($volumeEvent->metadata['changed_fields'])->toEqualCanonicalizing(['s3_storage_id', 's3_storages']); }); test('configuring the instance database backup records an audit event', function () { diff --git a/tests/Feature/VolumeBackupMultipleS3DestinationsTest.php b/tests/Feature/VolumeBackupMultipleS3DestinationsTest.php new file mode 100644 index 0000000000..3eadab443a --- /dev/null +++ b/tests/Feature/VolumeBackupMultipleS3DestinationsTest.php @@ -0,0 +1,386 @@ + 'null']); + defined('CURLOPT_RESOLVE') || define('CURLOPT_RESOLVE', 10203); +}); + +/** + * @return array{0: ScheduledVolumeBackup, 1: S3Storage, 2: S3Storage, 3: Server} + */ +function createMultiDestinationVolumeBackup(array $backupAttributes = []): array +{ + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + $team = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'ip' => '203.0.113.10', + ]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + $storages = collect(['first', 'second'])->map(fn (string $name): S3Storage => S3Storage::create([ + 'name' => "{$name} storage", + 'region' => 'us-east-1', + 'key' => "{$name}-key", + 'secret' => 'secret', + 'bucket' => "{$name}-bucket", + 'endpoint' => 'https://s3.amazonaws.com', + 'team_id' => $team->id, + 'is_usable' => true, + ])); + $backup = $volume->scheduledBackups()->create([ + 'team_id' => $team->id, + 'frequency' => 'daily', + 'save_s3' => true, + ...$backupAttributes, + ]); + $backup->syncS3Storages($storages->pluck('id')->all()); + + return [$backup->fresh(), $storages[0], $storages[1], $server]; +} + +function fakeMultiDestinationS3Disks(): void +{ + $sshDisk = Storage::fake('ssh-keys'); + $disk = Mockery::mock(FilesystemAdapter::class); + $disk->shouldReceive('files')->zeroOrMoreTimes()->andReturn([]); + $disk->shouldReceive('delete')->zeroOrMoreTimes()->andReturnTrue(); + Storage::shouldReceive('disk')->with('ssh-keys')->andReturn($sshDisk); + Storage::shouldReceive('build')->zeroOrMoreTimes()->andReturn($disk); +} + +it('archives locally and uploads to every destination instead of streaming', function () { + [$backup, $first, $second] = createMultiDestinationVolumeBackup(['disable_local_backup' => true]); + fakeMultiDestinationS3Disks(); + Process::fake([ + '*du -b*' => '128', + '*' => '', + ]); + + (new VolumeBackupJob($backup))->handle(); + + $execution = ScheduledVolumeBackupExecution::query()->sole(); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'mc pipe')); + Process::assertRan(fn ($process) => str_contains($process->command, 'tar -I') && str_contains($process->command, ' > ')); + Process::assertRan(fn ($process) => str_contains($process->command, 'mc cp') && str_contains($process->command, 'temporary/first-bucket/')); + Process::assertRan(fn ($process) => str_contains($process->command, 'mc cp') && str_contains($process->command, 'temporary/second-bucket/')); + Process::assertRan(fn ($process) => preg_match("/rm -f '[^']+\\.tar\\.gz'/", $process->command) === 1); + expect($execution->status)->toBe('success') + ->and($execution->s3_storage_id)->toBe($first->id) + ->and($execution->s3_uploaded)->toBeTrue() + ->and($execution->local_storage_deleted)->toBeTrue() + ->and($execution->s3Replicas()->orderBy('s3_storage_id')->get()->map->only(['s3_storage_id', 's3_uploaded'])->all())->toBe([ + ['s3_storage_id' => $first->id, 's3_uploaded' => true], + ['s3_storage_id' => $second->id, 's3_uploaded' => true], + ]); +}); + +it('keeps the local archive and cleans the partial upload when one destination fails', function () { + [$backup, $first, $second] = createMultiDestinationVolumeBackup(['disable_local_backup' => true]); + fakeMultiDestinationS3Disks(); + Process::fake([ + '*du -b*' => '128', + '*temporary/second-bucket/*' => Process::result(errorOutput: 'access denied', exitCode: 1), + '*' => '', + ]); + + (new VolumeBackupJob($backup))->handle(); + + $execution = ScheduledVolumeBackupExecution::query()->sole(); + $firstReplica = $execution->s3Replicas()->where('s3_storage_id', $first->id)->sole(); + $secondReplica = $execution->s3Replicas()->where('s3_storage_id', $second->id)->sole(); + Process::assertNotRan(fn ($process) => preg_match("/rm -f '[^']+\\.tar\\.gz'/", $process->command) === 1); + expect($execution->status)->toBe('success') + ->and($execution->message)->toContain('S3 upload failed: second storage:') + ->and($execution->s3_uploaded)->toBeFalse() + ->and($execution->s3_cleanup_pending)->toBeFalse() + ->and($execution->local_storage_deleted)->toBeFalse() + ->and($firstReplica->s3_uploaded)->toBeTrue() + ->and($firstReplica->s3_storage_deleted)->toBeFalse() + ->and($secondReplica->s3_uploaded)->toBeFalse() + ->and($secondReplica->s3_storage_deleted)->toBeTrue() + ->and($secondReplica->message)->not->toBeEmpty(); +}); + +it('applies S3 retention to each destination separately', function () { + [$backup, $first, $second, $server] = createMultiDestinationVolumeBackup([ + 'retention_amount_locally' => 0, + 'retention_days_locally' => 0, + 'retention_max_storage_locally' => 0, + 'retention_amount_s3' => 1, + 'retention_days_s3' => 0, + 'retention_max_storage_s3' => 0, + ]); + $createExecution = function (string $name, int $daysAgo, array $uploadedTo) use ($backup, $first, $second): ScheduledVolumeBackupExecution { + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 's3_storage_id' => $first->id, + 'status' => 'success', + 'filename' => "/data/coolify/backups/volumes/test/{$name}.tar.gz", + 'local_storage_deleted' => true, + 'created_at' => now()->subDays($daysAgo), + ]); + foreach ([$first, $second] as $storage) { + $execution->s3Replicas()->create([ + 's3_storage_id' => $storage->id, + 's3_uploaded' => in_array($storage->id, $uploadedTo, true), + ]); + } + $execution->refreshS3Summary(); + + return $execution; + }; + $oldest = $createExecution('oldest', 3, [$first->id]); + $older = $createExecution('older', 2, [$first->id, $second->id]); + $latest = $createExecution('latest', 1, [$first->id]); + $disk = Mockery::mock(); + $disk->shouldReceive('delete') + ->once() + ->with(['/data/coolify/backups/volumes/test/older.tar.gz', '/data/coolify/backups/volumes/test/oldest.tar.gz']) + ->andReturnTrue(); + Storage::shouldReceive('build') + ->once() + ->with(Mockery::on(fn (array $config): bool => $config['key'] === 'first-key')) + ->andReturn($disk); + $job = new VolumeBackupJob($backup); + + (new ReflectionClass($job))->getMethod('removeExpiredBackups')->invoke($job, $server); + + expect($oldest->fresh())->toBeNull() + ->and($older->fresh())->not->toBeNull() + ->and($older->s3Replicas()->where('s3_storage_id', $first->id)->sole()->s3_storage_deleted)->toBeTrue() + ->and($older->s3Replicas()->where('s3_storage_id', $second->id)->sole()->s3_storage_deleted)->toBeFalse() + ->and($older->fresh()->s3_storage_deleted)->toBeFalse() + ->and($latest->s3Replicas()->where('s3_storage_id', $first->id)->sole()->s3_storage_deleted)->toBeFalse(); +}); + +it('continues retention and execution cleanup when a replica storage is missing', function () { + $this->freezeTime(); + [$backup, , $second, $server] = createMultiDestinationVolumeBackup([ + 'retention_amount_locally' => 0, + 'retention_days_locally' => 0, + 'retention_max_storage_locally' => 0, + 'retention_amount_s3' => 0, + 'retention_days_s3' => 7, + 'retention_max_storage_s3' => 0, + ]); + $executions = collect([false, true])->map(function (bool $localDeleted) use ($backup, $second): ScheduledVolumeBackupExecution { + $execution = $backup->executions()->create([ + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/test/'.($localDeleted ? 'deleted' : 'local').'.tar.gz', + 'local_storage_deleted' => $localDeleted, + ]); + $execution->forceFill(['created_at' => now()->subDays(10)])->save(); + $execution->s3Replicas()->create(['s3_storage_id' => null, 's3_uploaded' => true]); + $execution->s3Replicas()->create(['s3_storage_id' => $second->id, 's3_uploaded' => true]); + $execution->refreshS3Summary(); + + return $execution; + }); + $disk = Mockery::mock(); + $disk->shouldReceive('delete')->once()->with(Mockery::on( + fn (array $filenames): bool => count($filenames) === 2 + && in_array($executions[0]->filename, $filenames, true) + && in_array($executions[1]->filename, $filenames, true), + ))->andReturnTrue(); + Storage::shouldReceive('build')->once() + ->with(Mockery::on(fn (array $config): bool => $config['key'] === 'second-key')) + ->andReturn($disk); + $job = new VolumeBackupJob($backup); + + (new ReflectionClass($job))->getMethod('removeExpiredBackups')->invoke($job, $server); + + expect($executions[0]->fresh()->s3_storage_deleted)->toBeTrue() + ->and($executions[0]->s3Replicas()->where('s3_storage_deleted', false)->count())->toBe(0); + $this->assertModelMissing($executions[1]); +}); + +it('cleans an interrupted upload from every destination whose copy did not finish', function () { + [$backup, $first, $second] = createMultiDestinationVolumeBackup(); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 's3_storage_id' => $first->id, + 'status' => 'failed', + 'filename' => '/data/coolify/backups/volumes/test/interrupted.tar.gz', + 's3_cleanup_pending' => true, + ]); + $uploaded = $execution->s3Replicas()->create(['s3_storage_id' => $first->id, 's3_uploaded' => true]); + $interrupted = $execution->s3Replicas()->create(['s3_storage_id' => $second->id]); + $withoutStorage = $execution->s3Replicas()->create(['s3_storage_id' => null, 's3_uploaded' => false]); + $disk = Mockery::mock(); + $disk->shouldReceive('delete')->once()->with(['/data/coolify/backups/volumes/test/interrupted.tar.gz'])->andReturnTrue(); + Storage::shouldReceive('build') + ->once() + ->with(Mockery::on(fn (array $config): bool => $config['key'] === 'second-key')) + ->andReturn($disk); + + VolumeBackupRecoveryJob::cleanupS3Upload($execution); + + expect($execution->fresh()->s3_cleanup_pending)->toBeFalse() + ->and($uploaded->fresh()->s3_storage_deleted)->toBeFalse() + ->and($interrupted->fresh()->s3_storage_deleted)->toBeTrue() + ->and($withoutStorage->fresh()->s3_storage_deleted)->toBeTrue(); +}); + +it('keeps the cleanup pending when one destination cannot delete the partial upload', function () { + [$backup, $first, $second] = createMultiDestinationVolumeBackup(); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 's3_storage_id' => $first->id, + 'status' => 'failed', + 'filename' => '/data/coolify/backups/volumes/test/interrupted.tar.gz', + 's3_cleanup_pending' => true, + ]); + $failing = $execution->s3Replicas()->create(['s3_storage_id' => $first->id, 's3_uploaded' => false]); + $cleaned = $execution->s3Replicas()->create(['s3_storage_id' => $second->id, 's3_uploaded' => false]); + $failingDisk = Mockery::mock(); + $failingDisk->shouldReceive('delete')->once()->andReturnFalse(); + $cleanedDisk = Mockery::mock(); + $cleanedDisk->shouldReceive('delete')->once()->andReturnTrue(); + Storage::shouldReceive('build')->with(Mockery::on(fn (array $config): bool => $config['key'] === 'first-key'))->andReturn($failingDisk); + Storage::shouldReceive('build')->with(Mockery::on(fn (array $config): bool => $config['key'] === 'second-key'))->andReturn($cleanedDisk); + + expect(fn () => VolumeBackupRecoveryJob::cleanupS3Upload($execution))->toThrow(RuntimeException::class); + + expect($execution->fresh()->s3_cleanup_pending)->toBeTrue() + ->and($failing->fresh()->s3_storage_deleted)->toBeFalse() + ->and($cleaned->fresh()->s3_storage_deleted)->toBeTrue(); +}); + +it('moves the primary destination to a remaining storage when the primary storage is deleted', function () { + [$backup, $first, $second] = createMultiDestinationVolumeBackup(); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 's3_storage_id' => $first->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz', + ]); + $execution->s3Replicas()->create(['s3_storage_id' => $first->id, 's3_uploaded' => true]); + $execution->s3Replicas()->create(['s3_storage_id' => $second->id, 's3_uploaded' => true]); + $execution->refreshS3Summary(); + + expect($backup->s3_storage_id)->toBe($first->id); + + $first->delete(); + + $backup->refresh(); + expect($backup->save_s3)->toBeTrue() + ->and($backup->s3_storage_id)->toBe($second->id) + ->and($backup->s3Storages()->pluck('s3_storages.id')->all())->toBe([$second->id]) + ->and($execution->fresh()->s3_storage_deleted)->toBeFalse() + ->and($execution->hasLiveS3Copies())->toBeTrue(); +}); + +it('preserves the schedule and uploaded replica when its S3 storage is unavailable', function () { + [$backup] = createMultiDestinationVolumeBackup(); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz', + 'local_storage_deleted' => true, + ]); + $replica = $execution->s3Replicas()->create([ + 's3_storage_id' => null, + 's3_uploaded' => true, + 's3_storage_deleted' => false, + ]); + $execution->refreshS3Summary(); + + expect(fn () => (new DeleteScheduledVolumeBackup)->handle($backup, deleteLocalArchives: false)) + ->toThrow(RuntimeException::class, 'The S3 storage used by an existing backup is unavailable.'); + + expect($backup->fresh())->not->toBeNull(); + expect($replica->fresh()->s3_storage_deleted)->toBeFalse(); + expect($execution->fresh()->s3_storage_deleted)->toBeFalse(); + expect($execution->hasLiveS3Copies())->toBeTrue(); +}); + +it('rolls back all backup detachment changes when an execution update fails', function () { + [$backup, $storage] = createMultiDestinationVolumeBackup(); + $backup->syncS3Storages([$storage->id]); + $databaseBackup = ScheduledDatabaseBackup::create([ + 'frequency' => 'daily', + 'save_s3' => true, + 'database_type' => 'App\\Models\\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $storage->team_id, + ]); + $databaseBackup->syncS3Storages([$storage->id]); + $databaseExecution = $databaseBackup->executions()->create([ + 'uuid' => 'rollback-database', + 'database_name' => 'app', + 'status' => 'success', + 's3_uploaded' => true, + ]); + $databaseReplica = $databaseExecution->s3Replicas()->create([ + 's3_storage_id' => $storage->id, + 's3_uploaded' => true, + ]); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 's3_storage_id' => $storage->id, + 'status' => 'failed', + 's3_cleanup_pending' => true, + ]); + $replica = $execution->s3Replicas()->create([ + 's3_storage_id' => $storage->id, + 's3_uploaded' => false, + ]); + Event::listen('eloquent.updating: '.ScheduledVolumeBackupExecution::class, function (): void { + throw new RuntimeException('Execution update failed'); + }); + + expect(fn () => $storage->delete())->toThrow(RuntimeException::class, 'Execution update failed'); + + $this->assertModelExists($storage); + foreach ([$databaseBackup, $backup] as $schedule) { + expect($schedule->fresh()->s3_storage_id)->toBe($storage->id) + ->and((bool) $schedule->fresh()->save_s3)->toBeTrue() + ->and($schedule->s3Storages()->pluck('s3_storages.id')->all())->toBe([$storage->id]); + } + expect($databaseReplica->fresh()->s3_storage_deleted)->toBeFalse() + ->and($databaseExecution->fresh()->s3_storage_deleted)->toBeFalse() + ->and($replica->fresh()->s3_storage_deleted)->toBeFalse() + ->and($execution->fresh()->s3_cleanup_pending)->toBeTrue(); +}); diff --git a/tests/Feature/VolumeBackupRecoveryBoundsTest.php b/tests/Feature/VolumeBackupRecoveryBoundsTest.php index ff3ed2d031..08d90ab774 100644 --- a/tests/Feature/VolumeBackupRecoveryBoundsTest.php +++ b/tests/Feature/VolumeBackupRecoveryBoundsTest.php @@ -93,7 +93,7 @@ function createRecoveryBoundsBackup(Team $team, array $backupAttributes = []): a function createRecoveryBoundsExecution(ScheduledVolumeBackup $backup, array $attributes = []): ScheduledVolumeBackupExecution { - return ScheduledVolumeBackupExecution::create([ + $execution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 's3_storage_id' => $backup->s3_storage_id, 'status' => 'failed', @@ -101,6 +101,9 @@ function createRecoveryBoundsExecution(ScheduledVolumeBackup $backup, array $att 's3_cleanup_pending' => true, ...$attributes, ]); + $execution->s3Replicas()->create(['s3_storage_id' => $backup->s3_storage_id]); + + return $execution; } function failRecoveryBoundsS3Delete(string $awsCode): void @@ -164,7 +167,7 @@ it('records a categorized S3 cleanup failure without throwing or leaking the S3 $execution->refresh(); expect($execution->s3_cleanup_pending)->toBeTrue() - ->and($execution->s3_storage_deleted)->toBeFalse() + ->and($execution->s3Replicas()->sole()->s3_storage_deleted)->toBeFalse() ->and($execution->recovery_error)->toBe('s3_auth') ->and($execution->recovery_last_attempt_at->equalTo(now()))->toBeTrue() ->and($execution->recovery_needs_attention)->toBeTrue() @@ -261,7 +264,7 @@ it('cleans the S3 upload even when container recovery fails', function () { $execution->refresh(); expect($execution->stop_recovery_pending)->toBeTrue() ->and($execution->s3_cleanup_pending)->toBeFalse() - ->and($execution->s3_storage_deleted)->toBeTrue() + ->and($execution->s3Replicas()->sole()->s3_storage_deleted)->toBeTrue() ->and($execution->recovery_error)->toBe('server_unreachable') ->and($execution->recovery_needs_attention)->toBeFalse(); Notification::assertNothingSent(); @@ -281,7 +284,7 @@ it('resets the recovery state after a successful attempt', function () { $execution->refresh(); expect($execution->s3_cleanup_pending)->toBeFalse() - ->and($execution->s3_storage_deleted)->toBeTrue() + ->and($execution->s3Replicas()->sole()->s3_storage_deleted)->toBeTrue() ->and($execution->recovery_error)->toBeNull() ->and($execution->recovery_last_attempt_at)->toBeNull() ->and($execution->recovery_needs_attention)->toBeFalse(); diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index d247a7a4e6..66eadf98ca 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -867,6 +867,7 @@ it('deletes an individual S3 archive from the storage recorded on its execution' 'local_storage_deleted' => true, 's3_uploaded' => true, ]); + $execution->s3Replicas()->create(['s3_storage_id' => $originalStorage->id, 's3_uploaded' => true]); $disk = Mockery::mock(); $disk->shouldReceive('delete') ->once() @@ -981,7 +982,7 @@ it('persists S3 settings the first time when a volume backup schedule does not e 'resource' => $application, 'section' => 's3', ]) - ->assertSet('s3StorageId', $s3Storage->id) + ->assertSet('s3StorageIds', [$s3Storage->id]) ->call('toggleS3') ->assertSet('saveToS3', true) ->assertDispatched('success'); @@ -1013,7 +1014,7 @@ it('persists the selected S3 storage when a volume backup schedule does not exis 'resource' => $application, 'section' => 's3', ]) - ->set('s3StorageId', $s3Storage->id) + ->set('s3StorageIds', [$s3Storage->id]) ->assertDispatched('success'); $backup = ScheduledVolumeBackup::query()->sole(); @@ -1136,7 +1137,7 @@ it('does not change S3 storage when another volume backup setting is invalid', f 'section' => 's3', ]) ->set('frequency', 'not a valid schedule') - ->set('s3StorageId', $secondS3Storage->id) + ->set('s3StorageIds', [$secondS3Storage->id]) ->assertHasErrors('frequency') ->assertNotDispatched('success'); @@ -1254,18 +1255,18 @@ it('lists and accepts only S3 storages of the resource team when the session tea $component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]); expect($component->get('availableS3Storages')->pluck('id')->all())->toBe([$resourceTeamStorage->id]) - ->and($component->get('s3StorageId'))->toBe($resourceTeamStorage->id); + ->and($component->get('s3StorageIds'))->toBe([$resourceTeamStorage->id]); $component->set('frequency', 'daily') ->set('saveToS3', true) - ->set('s3StorageId', $otherTeamStorage->id) - ->assertHasErrors('s3StorageId') + ->set('s3StorageIds', [$otherTeamStorage->id]) + ->assertHasErrors('s3StorageIds') ->call('save') - ->assertHasErrors('s3StorageId'); + ->assertHasErrors('s3StorageIds'); expect(ScheduledVolumeBackup::query()->count())->toBe(0); - $component->set('s3StorageId', $resourceTeamStorage->id) + $component->set('s3StorageIds', [$resourceTeamStorage->id]) ->call('save') ->assertHasNoErrors(); @@ -1295,9 +1296,9 @@ it('only accepts a usable S3 storage owned by the current team', function () { Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) ->set('frequency', 'daily') ->set('saveToS3', true) - ->set('s3StorageId', $foreignStorage->id) + ->set('s3StorageIds', [$foreignStorage->id]) ->call('save') - ->assertHasErrors('s3StorageId'); + ->assertHasErrors('s3StorageIds'); expect(ScheduledVolumeBackup::query()->count())->toBe(0); }); @@ -1322,7 +1323,7 @@ it('saves the database-style S3 backup controls immediately', function () { ]); $component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) - ->assertSet('s3StorageId', $s3Storage->id) + ->assertSet('s3StorageIds', [$s3Storage->id]) ->set('saveToS3', true) ->call('instantSave') ->set('disableLocalBackup', true) @@ -1378,7 +1379,7 @@ it('saves the selected volume backup S3 storage immediately while S3 is disabled ]); Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application, 'section' => 's3']) - ->set('s3StorageId', $secondS3Storage->id) + ->set('s3StorageIds', [$secondS3Storage->id]) ->assertDispatched('success'); $backup = ScheduledVolumeBackup::query()->sole(); @@ -1486,6 +1487,7 @@ it('disables S3 volume backups when the storage is deleted', function () { 's3_uploaded' => true, 's3_cleanup_pending' => true, ]); + $execution->s3Replicas()->create(['s3_storage_id' => $s3Storage->id, 's3_uploaded' => true]); $s3Storage->delete(); @@ -1530,6 +1532,7 @@ it('marks historical executions deleted when their recorded S3 storage is remove 's3_uploaded' => true, 's3_cleanup_pending' => true, ]); + $execution->s3Replicas()->create(['s3_storage_id' => $originalStorage->id, 's3_uploaded' => true]); $originalStorage->delete(); @@ -1704,7 +1707,7 @@ it('deletes S3 archives from the storage recorded on each execution', function ( 'frequency' => 'daily', 'save_s3' => true, ]); - ScheduledVolumeBackupExecution::create([ + $execution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 's3_storage_id' => $originalStorage->id, 'status' => 'success', @@ -1712,6 +1715,7 @@ it('deletes S3 archives from the storage recorded on each execution', function ( 'local_storage_deleted' => true, 's3_uploaded' => true, ]); + $execution->s3Replicas()->create(['s3_storage_id' => $originalStorage->id, 's3_uploaded' => true]); $disk = Mockery::mock(); $disk->shouldReceive('delete') ->once() @@ -1977,7 +1981,8 @@ it('keeps the upload destination on the volume backup execution', function () { $execution = ScheduledVolumeBackupExecution::query()->sole(); expect($execution->s3_storage_id)->toBe($s3Storage->id) - ->and($execution->s3->is($s3Storage))->toBeTrue(); + ->and($execution->s3->is($s3Storage))->toBeTrue() + ->and($execution->s3Replicas()->sole()->s3_storage_id)->toBe($s3Storage->id); }); it('streams S3-only volume backups without creating or copying a local archive', function () { @@ -2032,6 +2037,7 @@ it('streams S3-only volume backups without creating or copying a local archive', expect($execution->status)->toBe('success') ->and($execution->size)->toBe(128) ->and($execution->s3_uploaded)->toBeTrue() + ->and($execution->s3Replicas()->sole()->s3_uploaded)->toBeTrue() ->and($execution->local_storage_deleted)->toBeTrue(); }); @@ -2240,15 +2246,26 @@ it('removes retained S3 archives from the storage recorded on each execution', f 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/old.tar.gz', 's3_uploaded' => true, + 'created_at' => now()->subDays(2), + ]); + $oldExecution->s3Replicas()->create(['s3_storage_id' => $originalStorage->id, 's3_uploaded' => true]); + $retainedOriginalExecution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 's3_storage_id' => $originalStorage->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/test/retained-original.tar.gz', + 's3_uploaded' => true, 'created_at' => now()->subDay(), ]); - ScheduledVolumeBackupExecution::create([ + $retainedOriginalExecution->s3Replicas()->create(['s3_storage_id' => $originalStorage->id, 's3_uploaded' => true]); + $newExecution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 's3_storage_id' => $newStorage->id, 'status' => 'success', 'filename' => '/data/coolify/backups/volumes/test/new.tar.gz', 's3_uploaded' => true, ]); + $newExecution->s3Replicas()->create(['s3_storage_id' => $newStorage->id, 's3_uploaded' => true]); $disk = Mockery::mock(); $disk->shouldReceive('delete')->once()->with(['/data/coolify/backups/volumes/test/old.tar.gz'])->andReturnTrue(); Storage::shouldReceive('build') @@ -2260,7 +2277,10 @@ it('removes retained S3 archives from the storage recorded on each execution', f $method->invoke($job, $server); - expect($oldExecution->fresh()->s3_storage_deleted)->toBeTrue(); + expect($oldExecution->fresh()->s3_storage_deleted)->toBeTrue() + ->and($oldExecution->s3Replicas()->sole()->s3_storage_deleted)->toBeTrue() + ->and($retainedOriginalExecution->fresh()->s3_storage_deleted)->toBeFalse() + ->and($newExecution->fresh()->s3_storage_deleted)->toBeFalse(); }); it('does not query execution history when local retention is unlimited', function () { @@ -2514,6 +2534,7 @@ it('cleans an interrupted S3 upload and coordinates recovery with the backup loc 'filename' => '/data/coolify/backups/volumes/test/interrupted.tar.gz', 's3_cleanup_pending' => true, ]); + $execution->s3Replicas()->create(['s3_storage_id' => $s3Storage->id]); $disk = Mockery::mock(); $disk->shouldReceive('delete')->once()->andReturnTrue(); Storage::shouldReceive('build')->once()->andReturn($disk); @@ -2523,7 +2544,7 @@ it('cleans an interrupted S3 upload and coordinates recovery with the backup loc $job->handle(); expect($execution->fresh()->s3_cleanup_pending)->toBeFalse() - ->and($execution->fresh()->s3_storage_deleted)->toBeTrue(); + ->and($execution->s3Replicas()->sole()->s3_storage_deleted)->toBeTrue(); }); it('cleans an interrupted upload from the execution S3 storage after a schedule switch', function () { @@ -2562,6 +2583,7 @@ it('cleans an interrupted upload from the execution S3 storage after a schedule 'filename' => '/data/coolify/backups/volumes/test/interrupted.tar.gz', 's3_cleanup_pending' => true, ]); + $execution->s3Replicas()->create(['s3_storage_id' => $originalStorage->id]); $disk = Mockery::mock(); $disk->shouldReceive('delete')->once()->andReturnTrue(); Storage::shouldReceive('build') @@ -2572,7 +2594,7 @@ it('cleans an interrupted upload from the execution S3 storage after a schedule VolumeBackupRecoveryJob::cleanupS3Upload($execution); expect($execution->fresh()->s3_cleanup_pending)->toBeFalse() - ->and($execution->fresh()->s3_storage_deleted)->toBeTrue(); + ->and($execution->s3Replicas()->sole()->s3_storage_deleted)->toBeTrue(); }); it('keeps the S3 key tracked when interrupted upload cleanup must be retried', function () { @@ -2603,6 +2625,7 @@ it('keeps the S3 key tracked when interrupted upload cleanup must be retried', f 'filename' => '/data/coolify/backups/volumes/test/interrupted.tar.gz', 's3_cleanup_pending' => true, ]); + $execution->s3Replicas()->create(['s3_storage_id' => $s3Storage->id]); $disk = Mockery::mock(); $disk->shouldReceive('delete')->once()->andReturnFalse(); Storage::shouldReceive('build')->once()->andReturn($disk); diff --git a/tests/Unit/ServerTransfer/ServerTransferS3DestinationsTest.php b/tests/Unit/ServerTransfer/ServerTransferS3DestinationsTest.php new file mode 100644 index 0000000000..479367ee70 --- /dev/null +++ b/tests/Unit/ServerTransfer/ServerTransferS3DestinationsTest.php @@ -0,0 +1,144 @@ + 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']); + + $this->team = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + 'ip' => '10.56.0.10', + ]); + $destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = $project->environments()->first() + ?? Environment::factory()->create(['project_id' => $project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $volume = LocalPersistentVolume::create([ + 'name' => $this->application->uuid.'-data', + 'mount_path' => '/app/data', + 'resource_type' => $this->application->getMorphClass(), + 'resource_id' => $this->application->id, + ]); + + StandalonePostgresql::withoutEvents(function () use ($environment, $destination) { + $database = new StandalonePostgresql; + $database->forceFill([ + 'name' => 'app-db', + 'postgres_user' => 'postgres', + 'postgres_password' => 'secret', + 'postgres_db' => 'app', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $database->uuid = new_public_id(); + $database->save(); + $this->database = $database; + }); + + $this->firstS3 = S3Storage::create([ + 'name' => 'first', 'region' => 'us-east-1', 'key' => 'k1', 'secret' => 's1', + 'bucket' => 'first-bucket', 'endpoint' => 'https://first.example.com', 'team_id' => $this->team->id, + ]); + $this->secondS3 = S3Storage::create([ + 'name' => 'second', 'region' => 'us-east-1', 'key' => 'k2', 'secret' => 's2', + 'bucket' => 'second-bucket', 'endpoint' => 'https://second.example.com', 'team_id' => $this->team->id, + ]); + + $this->databaseBackup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'save_s3' => true, + 'frequency' => '0 2 * * *', + 's3_storage_id' => $this->secondS3->id, + 'database_type' => $this->database->getMorphClass(), + 'database_id' => $this->database->id, + ]); + $this->databaseBackup->syncS3Storages([$this->firstS3->id, $this->secondS3->id]); + + $this->volumeBackup = $volume->scheduledBackups()->create([ + 'team_id' => $this->team->id, + 'save_s3' => true, + 'frequency' => '0 3 * * *', + 's3_storage_id' => $this->secondS3->id, + ]); + $this->volumeBackup->syncS3Storages([$this->firstS3->id, $this->secondS3->id]); +}); + +/** + * @return list Buckets of the backup's destinations, primary first. + */ +function transferredBackupBuckets(ScheduledDatabaseBackup|ScheduledVolumeBackup $backup): array +{ + return $backup->selectedS3Storages()->pluck('bucket')->all(); +} + +function importTransferBundleIntoNewTeam(array $bundle, $context): Team +{ + $targetTeam = Team::factory()->create(); + $context->application->forceDelete(); + $context->database->forceDelete(); + $context->server->forceDelete(); + + app(ServerTransferImporter::class)->import($bundle, teamId: $targetTeam->id); + + return $targetTeam; +} + +test('transfer keeps every S3 destination of database and volume backups', function () { + $bundle = app(ServerTransferExporter::class)->export($this->server); + + expect(collect($bundle['s3_storages'])->pluck('uuid')->sort()->values()->all()) + ->toEqual(collect([$this->firstS3->uuid, $this->secondS3->uuid])->sort()->values()->all()); + + $targetTeam = importTransferBundleIntoNewTeam($bundle, $this); + + $databaseBackup = ScheduledDatabaseBackup::where('team_id', $targetTeam->id)->sole(); + $volumeBackup = ScheduledVolumeBackup::where('team_id', $targetTeam->id)->sole(); + + expect(transferredBackupBuckets($databaseBackup))->toBe(['second-bucket', 'first-bucket']) + ->and($databaseBackup->save_s3)->toBeTruthy() + ->and($databaseBackup->s3->team_id)->toBe($targetTeam->id) + ->and(transferredBackupBuckets($volumeBackup))->toBe(['second-bucket', 'first-bucket']) + ->and($volumeBackup->save_s3)->toBeTrue() + ->and($volumeBackup->s3->team_id)->toBe($targetTeam->id); +}); + +test('transfer imports bundles that only have the primary S3 destination', function () { + $bundle = app(ServerTransferExporter::class)->export($this->server); + data_forget($bundle, 'projects.*.environments.*.databases.*.scheduled_backups.*.s3_storage_uuids'); + data_forget($bundle, 'volume_backups.*.s3_storage_uuids'); + expect(data_get($bundle, 'volume_backups.0'))->not->toHaveKey('s3_storage_uuids'); + + $targetTeam = importTransferBundleIntoNewTeam($bundle, $this); + + expect(transferredBackupBuckets(ScheduledDatabaseBackup::where('team_id', $targetTeam->id)->sole()))->toBe(['second-bucket']) + ->and(transferredBackupBuckets(ScheduledVolumeBackup::where('team_id', $targetTeam->id)->sole()))->toBe(['second-bucket']); +}); From 856735613d70ec8cd9c00c3468d11b18554a4c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=8F=94=EF=B8=8F=20Peak?= <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:59:14 +0200 Subject: [PATCH 043/129] feat(backups): back up the coolify database before an update (#12093) --- app/Actions/Server/UpdateCoolify.php | 20 ++++++ app/Livewire/SettingsBackup.php | 24 +++++++ app/Models/InstanceSettings.php | 2 + ...ate_enabled_to_instance_settings_table.php | 28 ++++++++ .../views/livewire/settings-backup.blade.php | 15 +++- .../SettingsBackupBeforeUpdateTest.php | 47 +++++++++++++ tests/Unit/UpdateCoolifyTest.php | 70 +++++++++++++++++++ 7 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_10_12_002632_add_is_backup_before_update_enabled_to_instance_settings_table.php create mode 100644 tests/Feature/SettingsBackupBeforeUpdateTest.php diff --git a/app/Actions/Server/UpdateCoolify.php b/app/Actions/Server/UpdateCoolify.php index 3c60c34379..ddfc6578e4 100644 --- a/app/Actions/Server/UpdateCoolify.php +++ b/app/Actions/Server/UpdateCoolify.php @@ -2,7 +2,9 @@ namespace App\Actions\Server; +use App\Jobs\DatabaseBackupJob; use App\Models\Server; +use App\Models\StandalonePostgresql; use App\Services\CoolifyUpgradeStatus; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -22,6 +24,7 @@ class UpdateCoolify public function handle($manual_update = false) { if (isDev()) { + $this->backupDatabase(); Sleep::for(10)->seconds(); return; @@ -110,11 +113,28 @@ class UpdateCoolify ); } + $this->backupDatabase(); $this->update(); $settings->new_version_available = false; $settings->save(); } + private function backupDatabase(): void + { + $backup = StandalonePostgresql::find(0)?->scheduledBackups()->first(); + if (! $backup || ! instanceSettings()->is_backup_before_update_enabled) { + return; + } + + // Runs inline so the update waits for the result. dispatchSync() would go through the job's + // WithoutOverlapping middleware, which silently drops the run while the scheduled backup is running. + $job = new DatabaseBackupJob($backup); + $job->handle(); + if ($job->backup_log?->status !== 'success') { + throw new \Exception('The database backup failed, so the update was not started.'); + } + } + private function update() { $this->ensureNoUpgradeIsRunning(); diff --git a/app/Livewire/SettingsBackup.php b/app/Livewire/SettingsBackup.php index a7e8b89488..0f92b2ad34 100644 --- a/app/Livewire/SettingsBackup.php +++ b/app/Livewire/SettingsBackup.php @@ -46,6 +46,8 @@ class SettingsBackup extends Component #[Validate(['required'])] public string $postgres_password; + public bool $is_backup_before_update_enabled; + public function mount() { if (! isInstanceAdmin()) { @@ -75,6 +77,7 @@ class SettingsBackup extends Component $this->executions = $this->backup?->executions ?? []; } $this->settings = $settings; + $this->is_backup_before_update_enabled = $settings->is_backup_before_update_enabled; $this->s3s = $s3s; } @@ -136,6 +139,27 @@ class SettingsBackup extends Component } } + public function instantSave() + { + try { + $this->authorize('update', $this->settings); + $this->settings->is_backup_before_update_enabled = $this->is_backup_before_update_enabled; + $changedFields = auditChangedFields($this->settings); + $this->settings->save(); + if ($changedFields !== []) { + auditLog('ui.instance.settings.updated', [ + 'team_id' => null, + 'resource' => 'instance', + 'section' => 'backup', + 'changed_fields' => $changedFields, + ]); + } + $this->dispatch('success', 'Backup updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function submit() { try { diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 16a1d7ea30..87a7356920 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -21,6 +21,7 @@ class InstanceSettings extends Model 'public_port_max', 'do_not_track', 'is_auto_update_enabled', + 'is_backup_before_update_enabled', 'is_registration_enabled', 'disable_registration_when_oauth_enabled', 'next_channel', @@ -90,6 +91,7 @@ class InstanceSettings extends Model 'allowed_ip_ranges' => 'array', 'is_auto_update_enabled' => 'boolean', + 'is_backup_before_update_enabled' => 'boolean', 'is_registration_enabled' => 'boolean', 'disable_registration_when_oauth_enabled' => 'boolean', 'auto_update_frequency' => 'string', diff --git a/database/migrations/2026_10_12_002632_add_is_backup_before_update_enabled_to_instance_settings_table.php b/database/migrations/2026_10_12_002632_add_is_backup_before_update_enabled_to_instance_settings_table.php new file mode 100644 index 0000000000..ff6cc2509e --- /dev/null +++ b/database/migrations/2026_10_12_002632_add_is_backup_before_update_enabled_to_instance_settings_table.php @@ -0,0 +1,28 @@ +boolean('is_backup_before_update_enabled')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('is_backup_before_update_enabled'); + }); + } +}; diff --git a/resources/views/livewire/settings-backup.blade.php b/resources/views/livewire/settings-backup.blade.php index c89bf148cf..05673e9816 100644 --- a/resources/views/livewire/settings-backup.blade.php +++ b/resources/views/livewire/settings-backup.blade.php @@ -8,7 +8,8 @@ @if ($server->isFunctional()) @if (isset($database) && isset($backup))
    - + {{-- Exclude is_backup_before_update_enabled (instantSave) so the bar does not flash. --}} +
    @@ -26,6 +27,18 @@ + +
    + +
    +
    + @else diff --git a/tests/Feature/SettingsBackupBeforeUpdateTest.php b/tests/Feature/SettingsBackupBeforeUpdateTest.php new file mode 100644 index 0000000000..211e37ffd5 --- /dev/null +++ b/tests/Feature/SettingsBackupBeforeUpdateTest.php @@ -0,0 +1,47 @@ +create(['id' => 0]); + Server::factory()->create(['id' => 0, 'team_id' => 0, 'ip' => '127.0.0.1']); + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); + + $this->user = User::factory()->create(); + $rootTeam->members()->attach($this->user->id, ['role' => 'admin']); + $this->actingAs($this->user); + session(['currentTeam' => ['id' => 0]]); +}); + +test('instance admin can turn the backup before update off', function () { + Livewire::test(SettingsBackup::class) + ->assertSet('is_backup_before_update_enabled', true) + ->set('is_backup_before_update_enabled', false) + ->call('instantSave') + ->assertDispatched('success'); + + expect(InstanceSettings::find(0)->is_backup_before_update_enabled)->toBeFalse(); +}); + +test('team member cannot change the backup before update', function () { + $component = Livewire::test(SettingsBackup::class); + Team::find(0)->members()->updateExistingPivot($this->user->id, ['role' => 'member']); + $this->user->refresh(); + + $component->set('is_backup_before_update_enabled', false) + ->call('instantSave') + ->assertDispatched('error', 'This action is unauthorized.'); + + expect(InstanceSettings::find(0)->is_backup_before_update_enabled)->toBeTrue(); +}); diff --git a/tests/Unit/UpdateCoolifyTest.php b/tests/Unit/UpdateCoolifyTest.php index ddc8c2546b..087fc03514 100644 --- a/tests/Unit/UpdateCoolifyTest.php +++ b/tests/Unit/UpdateCoolifyTest.php @@ -3,7 +3,11 @@ use App\Actions\Server\UpdateCoolify; use App\Livewire\Settings\Updates; use App\Models\InstanceSettings; +use App\Models\PrivateKey; +use App\Models\ScheduledDatabaseBackup; use App\Models\Server; +use App\Models\StandaloneDocker; +use App\Models\StandalonePostgresql; use App\Models\Team; use App\Models\User; use App\Support\RemoteProcessCommand; @@ -11,8 +15,10 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Once; use Livewire\Livewire; use Spatie\Activitylog\Models\Activity; @@ -53,6 +59,40 @@ function updateCoolifyTestActionWithStatus(string $status): UpdateCoolify }; } +function updateCoolifyTestCreateInstanceBackup(): void +{ + Storage::fake('ssh-keys'); + Notification::fake(); + Server::query()->whereKey(0)->update(['private_key_id' => PrivateKey::factory()->create(['team_id' => 0])->id]); + Server::flushIdentityMap(); + StandalonePostgresql::forceCreate([ + 'id' => 0, + 'name' => 'coolify-db', + 'postgres_password' => 'password', + 'destination_type' => StandaloneDocker::class, + 'destination_id' => 0, + ]); + ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'database_id' => 0, + 'database_type' => StandalonePostgresql::class, + 'team_id' => 0, + ]); +} + +function updateCoolifyTestPrepareUpdate(array $settings = []): void +{ + Queue::fake(); + config([ + 'app.env' => 'testing', + 'constants.coolify.version' => '4.0.9', + 'constants.ssh.mux_enabled' => false, + ]); + Http::fake(['*' => Http::response(['coolify' => ['v4' => ['version' => '4.0.10']]], 200)]); + updateCoolifyTestCreateRootServerAndSettings($settings); + updateCoolifyTestCreateInstanceBackup(); +} + afterEach(function () { Mockery::close(); }); @@ -363,3 +403,33 @@ it('starts an upgrade when the previous upgrade status is older than the lock ex expect(RemoteProcessCommand::read(Activity::query()->latest('id')->first())) ->toContain("bash /data/coolify/source/upgrade.sh '4.0.10'"); }); + +it('backs up the instance database before the update', function () { + updateCoolifyTestPrepareUpdate(); + Process::fake(['*du -b*' => '128', '*' => '']); + + (new UpdateCoolify)->handle(manual_update: true); + + Process::assertRan(fn ($process) => str_contains($process->command, 'pg_dump')); + expect(RemoteProcessCommand::read(Activity::query()->latest('id')->first())) + ->toContain("bash /data/coolify/source/upgrade.sh '4.0.10'"); +}); + +it('skips the backup before the update when it is disabled', function () { + updateCoolifyTestPrepareUpdate(['is_backup_before_update_enabled' => false]); + Process::fake(); + + (new UpdateCoolify)->handle(manual_update: true); + + Process::assertNotRan(fn ($process) => str_contains($process->command, 'pg_dump')); + expect(Activity::query()->count())->toBe(1); +}); + +it('does not start the update when the instance backup fails', function () { + updateCoolifyTestPrepareUpdate(); + Process::fake(['*pg_dump*' => Process::result(exitCode: 1), '*' => '']); + + expect(fn () => (new UpdateCoolify)->handle(manual_update: true)) + ->toThrow(Exception::class, 'The database backup failed, so the update was not started.') + ->and(Activity::query()->count())->toBe(0); +}); From 7bed78c81e39aa26840ff48bd305db0f6202a1b7 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 13 Oct 2026 04:01:40 +0000 Subject: [PATCH 044/129] feat(auth): add passkey sign-in Enable Fortify passkeys (laravel/passkeys) with the @laravel/passkeys client. Users can sign in with a passkey, confirm sensitive actions with one, and add, rename, and delete passkeys on the Profile page. - Resolve the relying party for each request from the instance URL, then APP_URL, then localhost. Passkey routes return 404 on other hosts. - Add the is_passkey_enabled instance setting (Settings > Advanced). - Use a hex user handle (laravel/passkeys-server#41). - Rate limit the passkey routes by client IP. - Record audit events for registered, verified, and deleted passkeys. --- app/Http/Middleware/ConfigurePasskeys.php | 28 ++++ app/Livewire/Profile/Passkeys.php | 70 +++++++++ app/Livewire/Settings/Advanced.php | 6 + app/Models/InstanceSettings.php | 2 + app/Models/User.php | 18 ++- app/Providers/EventServiceProvider.php | 12 ++ app/Providers/FortifyServiceProvider.php | 7 +- app/Providers/RouteServiceProvider.php | 4 + app/Support/PasskeyRelyingParty.php | 49 +++++++ bootstrap/helpers/subscriptions.php | 5 + config/fortify.php | 23 ++- ...026_10_12_164601_create_passkeys_table.php | 34 +++++ ...key_enabled_to_instance_settings_table.php | 28 ++++ package-lock.json | 37 ++++- package.json | 1 + resources/js/passkeys.js | 22 +++ .../views/auth/confirm-password.blade.php | 5 + resources/views/auth/login.blade.php | 10 +- .../components/auth/passkey-verify.blade.php | 55 +++++++ .../views/livewire/profile/index.blade.php | 2 + .../views/livewire/profile/passkeys.blade.php | 104 +++++++++++++ .../livewire/settings/advanced.blade.php | 6 + .../views/livewire/settings/index.blade.php | 2 +- routes/web.php | 3 + tests/Feature/Auth/PasskeyAccessTest.php | 78 ++++++++++ .../Auth/PasskeyInstanceToggleTest.php | 39 +++++ .../Feature/Auth/PasskeyRelyingPartyTest.php | 100 +++++++++++++ tests/Feature/Auth/PasskeySetupTest.php | 42 ++++++ tests/Feature/ProfilePasskeysTest.php | 137 ++++++++++++++++++ vite.config.js | 1 + 30 files changed, 922 insertions(+), 8 deletions(-) create mode 100644 app/Http/Middleware/ConfigurePasskeys.php create mode 100644 app/Livewire/Profile/Passkeys.php create mode 100644 app/Support/PasskeyRelyingParty.php create mode 100644 database/migrations/2026_10_12_164601_create_passkeys_table.php create mode 100644 database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php create mode 100644 resources/js/passkeys.js create mode 100644 resources/views/components/auth/passkey-verify.blade.php create mode 100644 resources/views/livewire/profile/passkeys.blade.php create mode 100644 tests/Feature/Auth/PasskeyAccessTest.php create mode 100644 tests/Feature/Auth/PasskeyInstanceToggleTest.php create mode 100644 tests/Feature/Auth/PasskeyRelyingPartyTest.php create mode 100644 tests/Feature/Auth/PasskeySetupTest.php create mode 100644 tests/Feature/ProfilePasskeysTest.php diff --git a/app/Http/Middleware/ConfigurePasskeys.php b/app/Http/Middleware/ConfigurePasskeys.php new file mode 100644 index 0000000000..e5465c28a4 --- /dev/null +++ b/app/Http/Middleware/ConfigurePasskeys.php @@ -0,0 +1,28 @@ +routeIs('passkey.*')) { + return $next($request); + } + + $relyingParty = instanceSettings()->is_passkey_enabled ? PasskeyRelyingParty::resolve($request) : null; + abort_if($relyingParty === null, 404); + + config([ + 'passkeys.relying_party_id' => $relyingParty['id'], + 'passkeys.allowed_origins' => $relyingParty['origins'], + ]); + + return $next($request); + } +} diff --git a/app/Livewire/Profile/Passkeys.php b/app/Livewire/Profile/Passkeys.php new file mode 100644 index 0000000000..2620e92d90 --- /dev/null +++ b/app/Livewire/Profile/Passkeys.php @@ -0,0 +1,70 @@ +show_form = session('status') === 'passkey-add'; + } + + public function registered(): void + { + $this->show_form = false; + $this->dispatch('success', 'Passkey added.'); + } + + public function rename(int $id, string $name): bool + { + abort_unless(instanceSettings()->is_passkey_enabled, 404); + + $passkey = Auth::user()->passkeys()->findOrFail($id); + + $validated = Validator::make( + ['name' => trim($name)], + ['name' => ['required', 'string', 'max:255']], + )->validate(); + + $passkey->update(['name' => $validated['name']]); + $this->dispatch('success', 'Passkey renamed.'); + + return true; + } + + public function delete(int $id, mixed $password = null): mixed + { + abort_unless(instanceSettings()->is_passkey_enabled, 404); + + $passkey = Auth::user()->passkeys()->findOrFail($id); + + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + + app(DeletePasskey::class)(Auth::user(), $passkey); + $this->dispatch('success', 'Passkey deleted.'); + + return true; + } + + public function render(): View + { + $enabled = (bool) instanceSettings()->is_passkey_enabled; + + return view('livewire.profile.passkeys', [ + 'enabled' => $enabled, + 'available' => $enabled && PasskeyRelyingParty::isAvailable(), + 'passkeys' => $enabled ? Auth::user()->passkeys()->latest()->get() : collect(), + ]); + } +} diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index a3855eac3b..28cbc971d2 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -41,6 +41,9 @@ class Advanced extends Component #[Validate('boolean')] public bool $disable_two_step_confirmation; + #[Validate('boolean')] + public bool $is_passkey_enabled; + #[Validate('boolean')] public bool $is_wire_navigate_enabled; @@ -72,6 +75,7 @@ class Advanced extends Component 'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr], 'is_sponsorship_popup_enabled' => 'boolean', 'disable_two_step_confirmation' => 'boolean', + 'is_passkey_enabled' => 'boolean', 'is_wire_navigate_enabled' => 'boolean', 'is_mcp_server_enabled' => 'boolean', 'webhook_allowed_internal_hosts' => 'nullable|string', @@ -95,6 +99,7 @@ class Advanced extends Component $this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled; $this->is_api_enabled = $this->settings->is_api_enabled; $this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation; + $this->is_passkey_enabled = $this->settings->is_passkey_enabled ?? true; $this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled; $this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true; $this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false; @@ -216,6 +221,7 @@ class Advanced extends Component $this->settings->allowed_ips = $this->allowed_ips; $this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled; $this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation; + $this->settings->is_passkey_enabled = $this->is_passkey_enabled; $this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled; $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled; $this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? []; diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 16a1d7ea30..480beb9088 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -59,6 +59,7 @@ class InstanceSettings extends Model 'avatar_s3_storage_id', 'image_cdn_url', 'is_dashboard_force_https_enabled', + 'is_passkey_enabled', ]; protected $hidden = [ @@ -100,6 +101,7 @@ class InstanceSettings extends Model 'webhook_allowed_internal_hosts' => 'array', 'webhook_allow_localhost' => 'boolean', 'is_dashboard_force_https_enabled' => 'boolean', + 'is_passkey_enabled' => 'boolean', ]; protected static function booted(): void diff --git a/app/Models/User.php b/app/Models/User.php index c5e9f592c3..743871fcfc 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -21,6 +21,8 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\URL; use Illuminate\Support\Str; +use Laravel\Fortify\Contracts\PasskeyUser; +use Laravel\Fortify\PasskeyAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\NewAccessToken; @@ -41,9 +43,9 @@ use OpenApi\Attributes as OA; 'marketing_emails' => ['type' => 'boolean', 'description' => 'The flag to receive marketing emails.'], ], )] -class User extends Authenticatable implements SendsEmail +class User extends Authenticatable implements PasskeyUser, SendsEmail { - use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; + use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; protected $fillable = [ 'name', @@ -599,6 +601,18 @@ class User extends Authenticatable implements SendsEmail return $this->oauthIdentities()->exists(); } + /** + * Hex handle: iOS Safari corrupts raw binary handles (laravel/passkeys-server#41). + */ + public function getPasskeyUserHandle(): string + { + return hash_hmac( + 'sha256', + $this->getTable().'|'.$this->getKey(), + Config::string('passkeys.user_handle_secret'), + ); + } + /** * Check if the user has a password set. */ diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index e9da72d442..a894b7f3d9 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -14,6 +14,9 @@ use Laravel\Fortify\Events\RecoveryCodesGenerated; use Laravel\Fortify\Events\TwoFactorAuthenticationConfirmed; use Laravel\Fortify\Events\TwoFactorAuthenticationDisabled; use Laravel\Fortify\Events\TwoFactorAuthenticationEnabled; +use Laravel\Passkeys\Events\PasskeyDeleted; +use Laravel\Passkeys\Events\PasskeyRegistered; +use Laravel\Passkeys\Events\PasskeyVerified; use SocialiteProviders\Authentik\AuthentikExtendSocialite; use SocialiteProviders\Azure\AzureExtendSocialite; use SocialiteProviders\Clerk\ClerkExtendSocialite; @@ -72,6 +75,15 @@ class EventServiceProvider extends ServiceProvider Event::listen(RecoveryCodesGenerated::class, function (RecoveryCodesGenerated $event): void { auditLog('auth.user.recovery_codes_regenerated', $this->authContext($event->user)); }); + Event::listen(PasskeyRegistered::class, function (PasskeyRegistered $event): void { + auditLog('auth.user.passkey_registered', $this->authContext($event->user)); + }); + Event::listen(PasskeyVerified::class, function (PasskeyVerified $event): void { + auditLog('auth.user.passkey_verified', $this->authContext($event->user)); + }); + Event::listen(PasskeyDeleted::class, function (PasskeyDeleted $event): void { + auditLog('auth.user.passkey_deleted', $this->authContext($event->user)); + }); } /** diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 9bdc5d7371..d6519aac0e 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -7,6 +7,7 @@ use App\Actions\Fortify\ResetUserPassword; use App\Actions\Fortify\UpdateUserPassword; use App\Models\OauthSetting; use App\Models\User; +use App\Support\PasskeyRelyingParty; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Support\ServiceProvider; @@ -68,6 +69,7 @@ class FortifyServiceProvider extends ServiceProvider fn (OauthSetting $oauthSetting) => $oauthSetting->couldBeEnabled() && $oauthSetting->allowsUserCreation() ), 'enabled_oauth_providers' => $enabled_oauth_providers, + 'passkeys_available' => PasskeyRelyingParty::isAvailable(), ]); }); @@ -108,7 +110,10 @@ class FortifyServiceProvider extends ServiceProvider Fortify::updateUserPasswordsUsing(UpdateUserPassword::class); Fortify::confirmPasswordView(function () { - return view('auth.confirm-password'); + return view('auth.confirm-password', [ + 'can_confirm_with_passkey' => PasskeyRelyingParty::isAvailable() + && auth()->user()->hasPasskeysEnabled(), + ]); }); Fortify::twoFactorChallengeView(function () { diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 24cec00b4c..dedac3a095 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -74,6 +74,10 @@ class RouteServiceProvider extends ServiceProvider return Limit::perMinute(5)->by($request->session()->get('login.id')); }); + RateLimiter::for('passkeys', function (Request $request) { + return Limit::perMinute(30)->by('passkeys:'.auth_rate_limit_ip($request)); + }); + RateLimiter::for('forgot-password', function (Request $request) { $limits = [ Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1(auth_rate_limit_ip($request))), diff --git a/app/Support/PasskeyRelyingParty.php b/app/Support/PasskeyRelyingParty.php new file mode 100644 index 0000000000..c066feec41 --- /dev/null +++ b/app/Support/PasskeyRelyingParty.php @@ -0,0 +1,49 @@ +}|null + */ + public static function resolve(Request $request): ?array + { + $requestHost = $request->getHost(); + + foreach ([instanceSettings()->fqdn, config('app.url')] as $url) { + $parts = parse_url((string) $url); + $host = strtolower($parts['host'] ?? ''); + + if (strtolower($parts['scheme'] ?? '') !== 'https' || $host !== $requestHost) { + continue; + } + if (filter_var(trim($host, '[]'), FILTER_VALIDATE_IP) !== false) { + continue; + } + + $origins = ['https://'.$host.(isset($parts['port']) ? ':'.$parts['port'] : '')]; + + // The fqdn setting stores no port, so a custom port can only come from the request. + if (! isset($parts['port']) && ! in_array($request->getPort(), [80, 443])) { + $origins[] = 'https://'.$host.':'.$request->getPort(); + } + + return ['id' => $host, 'origins' => $origins]; + } + + if ($requestHost === 'localhost') { + return ['id' => 'localhost', 'origins' => [$request->getSchemeAndHttpHost()]]; + } + + return null; + } + + public static function isAvailable(?Request $request = null): bool + { + return (bool) instanceSettings()->is_passkey_enabled + && self::resolve($request ?? request()) !== null; + } +} diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index 971afdfb79..afe1631a19 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -85,6 +85,11 @@ function allowedPathsForUnsubscribedAccounts() // Account basics stay available without a paid plan. 'profile', 'profile/appearance', + 'profile/passkeys/confirm', + 'user/passkeys', + 'user/passkeys/options', + 'passkeys/confirm', + 'passkeys/confirm/options', ]; } function allowedPathsForBoardingAccounts() diff --git a/config/fortify.php b/config/fortify.php index 5b7864af01..03b1c70ae0 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -1,5 +1,6 @@ ['web'], + 'middleware' => ['web', ConfigurePasskeys::class], /* |-------------------------------------------------------------------------- @@ -106,6 +107,7 @@ return [ 'login' => 'login', 'two-factor' => 'two-factor', 'forgot-password' => 'forgot-password', + 'passkeys' => 'passkeys', ], /* @@ -121,6 +123,22 @@ return [ 'views' => true, + /* + |-------------------------------------------------------------------------- + | Passkeys + |-------------------------------------------------------------------------- + | + | Defaults only. ConfigurePasskeys sets the relying party for each request. + | + */ + + 'passkeys' => [ + 'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST), + 'allowed_origins' => [config('app.url')], + 'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')), + 'timeout' => 60000, + ], + /* |-------------------------------------------------------------------------- | Features @@ -142,6 +160,9 @@ return [ 'confirmPassword' => true, // 'window' => 0, ]), + Features::passkeys([ + 'confirmPassword' => true, + ]), ], ]; diff --git a/database/migrations/2026_10_12_164601_create_passkeys_table.php b/database/migrations/2026_10_12_164601_create_passkeys_table.php new file mode 100644 index 0000000000..cb884c3988 --- /dev/null +++ b/database/migrations/2026_10_12_164601_create_passkeys_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('credential_id')->unique(); + $table->json('credential'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('passkeys'); + } +}; diff --git a/database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php b/database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php new file mode 100644 index 0000000000..674486416d --- /dev/null +++ b/database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php @@ -0,0 +1,28 @@ +boolean('is_passkey_enabled')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('is_passkey_enabled'); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index 3f21ba9329..f70d8ae9ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "coolify", "dependencies": { + "@laravel/passkeys": "^0.4.0", "@tailwindcss/forms": "0.5.11", "@tailwindcss/typography": "0.5.20", "@tanstack/virtual-core": "3.17.11", @@ -87,6 +88,34 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@laravel/passkeys": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@laravel/passkeys/-/passkeys-0.4.0.tgz", + "integrity": "sha512-59UHJHZPrIT8f5hFQJUR1EXhazWZTg3DcJefxSfA3aRj6xu40I/lYDveUY4oL2/h/crq9jjptSKykZDDold7aA==", + "license": "MIT", + "dependencies": { + "@simplewebauthn/browser": "^13.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "svelte": "^5.0.0", + "vue": "^3.3.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/@oxc-project/types": { "version": "0.143.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", @@ -360,6 +389,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, "node_modules/@tailwindcss/forms": { "version": "0.5.11", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", @@ -1248,7 +1283,7 @@ "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "engines": { diff --git a/package.json b/package.json index 982aee11d7..741c243bf4 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "vite": "8.2.1" }, "dependencies": { + "@laravel/passkeys": "^0.4.0", "@tailwindcss/forms": "0.5.11", "@tailwindcss/typography": "0.5.20", "@tanstack/virtual-core": "3.17.11", diff --git a/resources/js/passkeys.js b/resources/js/passkeys.js new file mode 100644 index 0000000000..060e1ad00d --- /dev/null +++ b/resources/js/passkeys.js @@ -0,0 +1,22 @@ +import { Passkeys } from '@laravel/passkeys'; + +window.Passkeys = Passkeys; + +window.passkeyDeviceName = () => { + const ua = navigator.userAgent; + const browser = /Edg\//.test(ua) ? 'Edge' + : /Firefox\//.test(ua) ? 'Firefox' + : /Chrome\//.test(ua) ? 'Chrome' + : /Safari\//.test(ua) ? 'Safari' + : 'Browser'; + const os = /iPhone|iPad/.test(ua) ? 'iOS' + : /Android/.test(ua) ? 'Android' + : /Mac/.test(ua) ? 'Mac' + : /Windows/.test(ua) ? 'Windows' + : /Linux/.test(ua) ? 'Linux' + : 'device'; + + return `${browser} on ${os}`; +}; + +window.dispatchEvent(new CustomEvent('passkeys:ready')); diff --git a/resources/views/auth/confirm-password.blade.php b/resources/views/auth/confirm-password.blade.php index 9d2bc7be54..ee5cbb0858 100644 --- a/resources/views/auth/confirm-password.blade.php +++ b/resources/views/auth/confirm-password.blade.php @@ -28,6 +28,11 @@ {{ __('auth.confirm_password') }} + + @if ($can_confirm_with_passkey) + + @endif
    diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 476b153985..0788e43f13 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -22,12 +22,14 @@
    @csrf @env('local') - @else - @@ -78,6 +80,10 @@ + @if ($passkeys_available) + + @endif + @if ($enabled_oauth_providers->isNotEmpty())
    Or continue with
    diff --git a/resources/views/components/auth/passkey-verify.blade.php b/resources/views/components/auth/passkey-verify.blade.php new file mode 100644 index 0000000000..793e5998b6 --- /dev/null +++ b/resources/views/components/auth/passkey-verify.blade.php @@ -0,0 +1,55 @@ +@props([ + 'optionsRoute' => 'passkey.login-options', + 'submitRoute' => 'passkey.login', + 'label' => 'Sign in with a passkey', + 'autofill' => false, +]) + +@vite('resources/js/passkeys.js') +
    + + {{ $label }} + + +
    diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index 1c77a2d70e..b2ada6f238 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -300,6 +300,8 @@
    + +
    diff --git a/resources/views/livewire/profile/passkeys.blade.php b/resources/views/livewire/profile/passkeys.blade.php new file mode 100644 index 0000000000..86df71597d --- /dev/null +++ b/resources/views/livewire/profile/passkeys.blade.php @@ -0,0 +1,104 @@ +
    + @if ($enabled) + @assets + @vite('resources/js/passkeys.js') + @endassets +
    +
    +
    +

    Passkeys

    +

    Sign in with your fingerprint, face, or a security key. A passkey sign-in skips two-factor authentication.

    +
    + @if ($available && ! $show_form) + Add passkey + @endif +
    +
    + @unless ($available) + + Passkeys need HTTPS on the instance domain, or localhost. An IP address does not work. + + @endunless + + @if ($available && $show_form) +
    +
    + + Create passkey + Cancel +
    +

    +
    + @endif + + @forelse ($passkeys as $passkey) +
    +
    +
    {{ $passkey->name }}
    +
    + + Save + +

    + {{ $passkey->authenticator ?? 'Passkey' }} + · Added {{ $passkey->created_at->diffForHumans() }} + · {{ $passkey->last_used_at ? 'Last used '.$passkey->last_used_at->diffForHumans() : 'Not used yet' }} +

    +
    +
    + + + +
    + +
    +
    +
    + @empty + @if ($available && ! $show_form) + + @endif + @endforelse + + @error('name') +

    {{ $message }}

    + @enderror +
    +
    + @endif +
    diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index 0e6242d572..f437936faa 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -32,6 +32,12 @@ ['value' => false, 'label' => 'Require two-step confirmation'], ['value' => true, 'label' => 'Skip two-step confirmation'], ]" /> +
    diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php index 848642b6bb..f5a3ae95ce 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -15,7 +15,7 @@ 'lg:col-span-2' => !str_starts_with(strtolower($fqdn ?? ''), 'https://'), ])>
    diff --git a/routes/web.php b/routes/web.php index 3294c6edf7..4ab71748d7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -186,6 +186,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/profile', ProfileIndex::class)->name('profile'); Route::get('/profile/avatar', ProfileAvatarController::class)->name('profile.avatar'); Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance'); + Route::get('/profile/passkeys/confirm', fn () => redirect()->route('profile')->with('status', 'passkey-add')) + ->middleware('password.confirm') + ->name('profile.passkeys.confirm'); Route::prefix('tags')->group(function () { Route::get('/{tagName?}', TagsShow::class)->name('tags.show'); diff --git a/tests/Feature/Auth/PasskeyAccessTest.php b/tests/Feature/Auth/PasskeyAccessTest.php new file mode 100644 index 0000000000..2f11771a09 --- /dev/null +++ b/tests/Feature/Auth/PasskeyAccessTest.php @@ -0,0 +1,78 @@ +withoutDefer(); + config()->set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); + $this->withoutVite(); + Log::spy(); + + $this->user = User::factory()->create(['name' => 'Passkey User']); + $this->team = $this->user->teams()->sole(); +}); + +it('allows the authenticated passkey paths for unsubscribed and boarding accounts', function () { + expect(allowedPathsForBoardingAccounts())->toContain( + 'user/passkeys', + 'user/passkeys/options', + 'passkeys/confirm', + 'passkeys/confirm/options', + 'profile/passkeys/confirm', + ); +}); + +it('sets the session team after a login that did not set one', function () { + $this->actingAs($this->user)->get('/profile')->assertOk(); + + expect(session('currentTeam')->id)->toBe($this->team->id); +}); + +it('sends a multi team user without a stored team to the team selection', function () { + $other = Team::factory()->create(); + $other->members()->attach($this->user->id, ['role' => 'member']); + + $this->actingAs($this->user)->get('/profile')->assertRedirect(route('team.select')); +}); + +it('asks for the password before the add passkey form opens', function () { + $this->actingAs($this->user) + ->withSession(['currentTeam' => $this->team]) + ->get(route('profile.passkeys.confirm')) + ->assertRedirect(route('password.confirm')); + + $this->actingAs($this->user) + ->withSession(['currentTeam' => $this->team, 'auth.password_confirmed_at' => time()]) + ->get(route('profile.passkeys.confirm')) + ->assertRedirect(route('profile')) + ->assertSessionHas('status', 'passkey-add'); +}); + +it('records audit events for passkey changes', function (string $eventClass, string $auditName) { + $passkey = $this->user->passkeys()->create([ + 'name' => 'Laptop', + 'credential_id' => 'credential-1', + 'credential' => ['aaguid' => '00000000-0000-0000-0000-000000000000'], + ]); + + event(new $eventClass($this->user, $passkey)); + + expect(AuditEvent::query()->where('event', $auditName)->count())->toBe(1); +})->with([ + [PasskeyRegistered::class, 'auth.user.passkey_registered'], + [PasskeyVerified::class, 'auth.user.passkey_verified'], + [PasskeyDeleted::class, 'auth.user.passkey_deleted'], +]); diff --git a/tests/Feature/Auth/PasskeyInstanceToggleTest.php b/tests/Feature/Auth/PasskeyInstanceToggleTest.php new file mode 100644 index 0000000000..facac26208 --- /dev/null +++ b/tests/Feature/Auth/PasskeyInstanceToggleTest.php @@ -0,0 +1,39 @@ +withoutDefer(); + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); +}); + +it('lets an instance admin turn passkeys off and keeps stored passkeys', function () { + $rootTeam = Team::factory()->create(['id' => 0]); + $admin = User::factory()->create(); + $rootTeam->members()->attach($admin->id, ['role' => 'owner']); + $admin->passkeys()->create([ + 'name' => 'Laptop', + 'credential_id' => 'credential-1', + 'credential' => ['aaguid' => '00000000-0000-0000-0000-000000000000'], + ]); + + $this->actingAs($admin); + session(['currentTeam' => $rootTeam]); + + Livewire::test(Advanced::class) + ->assertSet('is_passkey_enabled', true) + ->set('is_passkey_enabled', false) + ->call('instantSave'); + + expect(InstanceSettings::findOrFail(0)->is_passkey_enabled)->toBeFalse() + ->and($admin->passkeys()->count())->toBe(1); +}); diff --git a/tests/Feature/Auth/PasskeyRelyingPartyTest.php b/tests/Feature/Auth/PasskeyRelyingPartyTest.php new file mode 100644 index 0000000000..d6008717e1 --- /dev/null +++ b/tests/Feature/Auth/PasskeyRelyingPartyTest.php @@ -0,0 +1,100 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); + $this->withoutVite(); +}); + +function setPasskeyInstance(?string $fqdn, string $appUrl, bool $enabled = true): void +{ + InstanceSettings::query()->where('id', 0)->update(['fqdn' => $fqdn, 'is_passkey_enabled' => $enabled]); + config()->set('app.url', $appUrl); + Once::flush(); +} + +it('resolves the relying party', function (?string $fqdn, string $appUrl, string $requestUrl, ?array $expected) { + setPasskeyInstance($fqdn, $appUrl); + + expect(PasskeyRelyingParty::resolve(Request::create($requestUrl)))->toBe($expected); +})->with([ + 'https fqdn' => ['https://coolify.example.com', 'http://localhost', 'https://coolify.example.com/login', + ['id' => 'coolify.example.com', 'origins' => ['https://coolify.example.com']]], + 'fqdn behind http proxy' => ['https://coolify.example.com', 'http://localhost', 'http://coolify.example.com/login', + ['id' => 'coolify.example.com', 'origins' => ['https://coolify.example.com']]], + 'app url fallback keeps the port' => [null, 'https://dev.example.ts.net:8443', 'https://dev.example.ts.net:8443/login', + ['id' => 'dev.example.ts.net', 'origins' => ['https://dev.example.ts.net:8443']]], + 'localhost uses the request origin' => [null, 'http://localhost', 'http://localhost:8000/login', + ['id' => 'localhost', 'origins' => ['http://localhost:8000']]], + 'fqdn on a custom port' => ['https://coolify.example.com', 'http://localhost', 'https://coolify.example.com:8443/login', + ['id' => 'coolify.example.com', 'origins' => ['https://coolify.example.com', 'https://coolify.example.com:8443']]], + 'fqdn with uppercase letters' => ['https://Coolify.Example.com', 'http://localhost', 'https://coolify.example.com/login', + ['id' => 'coolify.example.com', 'origins' => ['https://coolify.example.com']]], + 'http fqdn' => ['http://coolify.example.com', 'http://localhost', 'http://coolify.example.com/login', null], + 'raw ip' => [null, 'https://203.0.113.10', 'https://203.0.113.10/login', null], +]); + +it('is not available on a host that is not the instance host', function () { + setPasskeyInstance('https://coolify.example.com', 'http://localhost'); + + expect(PasskeyRelyingParty::isAvailable(Request::create('http://203.0.113.10:8000/login')))->toBeFalse(); +}); + +it('is not available when the instance toggle is off', function () { + setPasskeyInstance(null, 'http://localhost', enabled: false); + + expect(PasskeyRelyingParty::isAvailable(Request::create('http://localhost/login')))->toBeFalse(); +}); + +it('returns login options with the resolved relying party', function () { + setPasskeyInstance('https://coolify.example.com', 'http://localhost'); + + $this->getJson('https://coolify.example.com/passkeys/login/options') + ->assertOk() + ->assertJsonPath('options.rpId', 'coolify.example.com'); +}); + +it('returns 404 on passkey routes when passkeys are not available', function (bool $enabled, string $url) { + setPasskeyInstance(null, 'http://localhost', $enabled); + + $this->getJson($url)->assertNotFound(); +})->with([ + 'toggle off' => [false, 'http://localhost/passkeys/login/options'], + 'raw ip host' => [true, 'http://203.0.113.10:8000/passkeys/login/options'], +]); + +it('keeps the password login page reachable when passkeys are not available', function () { + setPasskeyInstance(null, 'http://localhost', enabled: false); + User::factory()->create(); + + $this->get('http://203.0.113.10:8000/login')->assertOk(); +}); + +it('rate limits the passkey routes by client ip', function () { + setPasskeyInstance(null, 'http://localhost'); + + foreach (range(1, 30) as $attempt) { + $this->getJson('/passkeys/login/options')->assertOk(); + } + + $this->getJson('/passkeys/login/options')->assertStatus(429); +}); + +it('does not need the instance settings on other fortify routes', function () { + InstanceSettings::query()->delete(); + Once::flush(); + + $this->post('/login', ['email' => 'nobody@example.com', 'password' => 'wrong-password']) + ->assertRedirect() + ->assertSessionHasErrors('email'); +}); diff --git a/tests/Feature/Auth/PasskeySetupTest.php b/tests/Feature/Auth/PasskeySetupTest.php new file mode 100644 index 0000000000..edd2b39c62 --- /dev/null +++ b/tests/Feature/Auth/PasskeySetupTest.php @@ -0,0 +1,42 @@ + 0]); + Once::flush(); +}); + +it('registers the fortify passkey routes', function () { + expect(Route::has('passkey.login-options'))->toBeTrue() + ->and(Route::has('passkey.login'))->toBeTrue() + ->and(Route::has('passkey.confirm'))->toBeTrue() + ->and(Route::has('passkey.store'))->toBeTrue(); +}); + +it('returns a stable 64 character hex user handle', function () { + $user = User::factory()->create(); + + $handle = $user->getPasskeyUserHandle(); + + expect($handle)->toMatch('/^[0-9a-f]{64}$/') + ->and(User::find($user->id)->getPasskeyUserHandle())->toBe($handle); +}); + +it('enables passkeys by default and stores passkeys for a user', function () { + $user = User::factory()->create(); + $user->passkeys()->create([ + 'name' => 'Laptop', + 'credential_id' => 'credential-1', + 'credential' => ['aaguid' => '00000000-0000-0000-0000-000000000000'], + ]); + + expect(instanceSettings()->is_passkey_enabled)->toBeTrue() + ->and($user->hasPasskeysEnabled())->toBeTrue(); +}); diff --git a/tests/Feature/ProfilePasskeysTest.php b/tests/Feature/ProfilePasskeysTest.php new file mode 100644 index 0000000000..b4d44d65b2 --- /dev/null +++ b/tests/Feature/ProfilePasskeysTest.php @@ -0,0 +1,137 @@ + 0]); + Once::flush(); + $this->withoutVite(); + + $this->user = User::factory()->create(['name' => 'Passkey User', 'password' => Hash::make('secret-password')]); + $this->actingAs($this->user); + session(['currentTeam' => $this->user->teams()->sole()]); +}); + +function profilePasskey(User $user, string $name = 'Laptop'): Passkey +{ + return $user->passkeys()->create([ + 'name' => $name, + 'credential_id' => 'credential-'.$user->id.'-'.$name, + 'credential' => ['aaguid' => '00000000-0000-0000-0000-000000000000'], + ]); +} + +it('lists only the passkeys of the current user', function () { + profilePasskey($this->user, 'My laptop'); + profilePasskey(User::factory()->create(), 'Other phone'); + + Livewire::test(Passkeys::class) + ->assertSee('My laptop') + ->assertDontSee('Other phone'); +}); + +it('opens the add form only after the confirm redirect', function () { + Livewire::test(Passkeys::class)->assertSet('show_form', false); + + session()->flash('status', 'passkey-add'); + + Livewire::test(Passkeys::class)->assertSet('show_form', true); +}); + +it('renames a passkey of the current user', function () { + $passkey = profilePasskey($this->user); + + Livewire::test(Passkeys::class)->call('rename', $passkey->id, ' Work laptop '); + + expect($passkey->fresh()->name)->toBe('Work laptop'); +}); + +it('rejects an empty or too long passkey name', function (string $name) { + $passkey = profilePasskey($this->user); + + Livewire::test(Passkeys::class)->call('rename', $passkey->id, $name)->assertHasErrors('name'); + + expect($passkey->fresh()->name)->toBe('Laptop'); +})->with(['empty' => [' '], 'too long' => [str_repeat('a', 256)]]); + +it('does not rename the passkey of another user', function () { + $passkey = profilePasskey(User::factory()->create()); + + expect(fn () => Livewire::test(Passkeys::class)->call('rename', $passkey->id, 'Stolen')) + ->toThrow(ModelNotFoundException::class); + + expect($passkey->fresh()->name)->toBe('Laptop'); +}); + +it('deletes a passkey with the correct password', function () { + $passkey = profilePasskey($this->user); + + Livewire::test(Passkeys::class)->call('delete', $passkey->id, 'secret-password'); + + expect(Passkey::find($passkey->id))->toBeNull(); +}); + +it('does not delete a passkey with a wrong password', function () { + $passkey = profilePasskey($this->user); + + Livewire::test(Passkeys::class)->call('delete', $passkey->id, 'wrong-password'); + + expect(Passkey::find($passkey->id))->not->toBeNull(); +}); + +it('does not delete the passkey of another user', function () { + $passkey = profilePasskey(User::factory()->create()); + + expect(fn () => Livewire::test(Passkeys::class)->call('delete', $passkey->id, 'secret-password')) + ->toThrow(ModelNotFoundException::class); + + expect(Passkey::find($passkey->id))->not->toBeNull(); +}); + +it('renders nothing when passkeys are turned off', function () { + InstanceSettings::query()->where('id', 0)->update(['is_passkey_enabled' => false]); + Once::flush(); + profilePasskey($this->user, 'My laptop'); + + Livewire::test(Passkeys::class)->assertDontSee('My laptop')->assertDontSee('Passkeys'); +}); + +it('refuses passkey changes when passkeys are turned off', function (string $action, array $arguments) { + $passkey = profilePasskey($this->user); + InstanceSettings::query()->where('id', 0)->update(['is_passkey_enabled' => false]); + Once::flush(); + + Livewire::test(Passkeys::class)->call($action, $passkey->id, ...$arguments)->assertNotFound(); + + expect($passkey->fresh()?->name)->toBe('Laptop'); +})->with([ + 'rename' => ['rename', ['Renamed']], + 'delete' => ['delete', ['secret-password']], +]); + +it('explains why a passkey cannot be added on an address that does not support passkeys', function () { + profilePasskey($this->user, 'My laptop'); + + $this->get('http://203.0.113.10:8000/profile') + ->assertOk() + ->assertSee('My laptop') + ->assertSee('Passkeys need HTTPS') + ->assertDontSee('Add passkey'); +}); + +it('reports if a rename was saved', function () { + $passkey = profilePasskey($this->user); + + Livewire::test(Passkeys::class)->call('rename', $passkey->id, 'Work laptop')->assertReturned(true); + Livewire::test(Passkeys::class)->call('rename', $passkey->id, ' ')->assertReturned(null); +}); diff --git a/vite.config.js b/vite.config.js index 1c7c5c47b3..efbe6b8fe1 100644 --- a/vite.config.js +++ b/vite.config.js @@ -43,6 +43,7 @@ export default defineConfig(({ mode }) => { input: [ "resources/css/app.css", "resources/js/app.js", + "resources/js/passkeys.js", ], refresh: true, }), From 12ffea0ab246611588fa3e7e0e83ce43fb434679 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 13 Oct 2026 06:54:26 +0000 Subject: [PATCH 045/129] refactor(auth): merge the passkey migrations into one --- ...026_10_12_164601_create_passkeys_table.php | 8 ++++++ ...key_enabled_to_instance_settings_table.php | 28 ------------------- 2 files changed, 8 insertions(+), 28 deletions(-) delete mode 100644 database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php diff --git a/database/migrations/2026_10_12_164601_create_passkeys_table.php b/database/migrations/2026_10_12_164601_create_passkeys_table.php index cb884c3988..97d6bf59b1 100644 --- a/database/migrations/2026_10_12_164601_create_passkeys_table.php +++ b/database/migrations/2026_10_12_164601_create_passkeys_table.php @@ -22,6 +22,10 @@ return new class extends Migration $table->index('user_id'); }); + + Schema::table('instance_settings', function (Blueprint $table) { + $table->boolean('is_passkey_enabled')->default(true); + }); } /** @@ -29,6 +33,10 @@ return new class extends Migration */ public function down(): void { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('is_passkey_enabled'); + }); + Schema::dropIfExists('passkeys'); } }; diff --git a/database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php b/database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php deleted file mode 100644 index 674486416d..0000000000 --- a/database/migrations/2026_10_12_164603_add_is_passkey_enabled_to_instance_settings_table.php +++ /dev/null @@ -1,28 +0,0 @@ -boolean('is_passkey_enabled')->default(true); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('instance_settings', function (Blueprint $table) { - $table->dropColumn('is_passkey_enabled'); - }); - } -}; From 2fa4d06ed6acc0a9667ca5b5431ff2b1c887c3f2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:09:48 +0200 Subject: [PATCH 046/129] fix(auth): allow restricted accounts to confirm passwords for passkeys --- bootstrap/helpers/subscriptions.php | 1 + tests/Feature/Auth/PasskeyAccessTest.php | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index afe1631a19..95442ca359 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -86,6 +86,7 @@ function allowedPathsForUnsubscribedAccounts() 'profile', 'profile/appearance', 'profile/passkeys/confirm', + 'user/confirm-password', 'user/passkeys', 'user/passkeys/options', 'passkeys/confirm', diff --git a/tests/Feature/Auth/PasskeyAccessTest.php b/tests/Feature/Auth/PasskeyAccessTest.php index 2f11771a09..8d2a9d5fca 100644 --- a/tests/Feature/Auth/PasskeyAccessTest.php +++ b/tests/Feature/Auth/PasskeyAccessTest.php @@ -61,6 +61,26 @@ it('asks for the password before the add passkey form opens', function () { ->assertSessionHas('status', 'passkey-add'); }); +it('lets restricted accounts confirm their password before adding a passkey', function (bool $selfHosted) { + config()->set('constants.coolify.self_hosted', $selfHosted); + $this->team->update(['show_boarding' => true]); + $this->actingAs($this->user)->withSession(['currentTeam' => $this->team]); + + $this->get(route('profile.passkeys.confirm'))->assertRedirect(route('password.confirm')); + $this->get(route('password.confirm'))->assertOk(); + $this->post(route('password.confirm.store'), ['password' => 'wrong-password']) + ->assertSessionHasErrors('password'); + expect(session('auth.password_confirmed_at'))->toBeNull(); + + $this->post(route('password.confirm.store'), ['password' => 'password']) + ->assertRedirect(route('profile.passkeys.confirm')); + expect(session('auth.password_confirmed_at'))->toBeInt(); + $this->get(route('profile.passkeys.confirm')) + ->assertRedirect(route('profile')) + ->assertSessionHas('status', 'passkey-add'); + $this->getJson(route('passkey.registration-options'))->assertOk(); +})->with(['onboarding' => true, 'cloud without a subscription' => false]); + it('records audit events for passkey changes', function (string $eventClass, string $auditName) { $passkey = $this->user->passkeys()->create([ 'name' => 'Laptop', From 5e39010543c3dcac7ff1dfb414c1691cdacf72e9 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 13 Oct 2026 08:27:33 +0000 Subject: [PATCH 047/129] fix(dev): define the s6 user bundle in user-bundles.d The serversideup/php base image now defines its user bundle (nginx, php-fpm) in /etc/s6-overlay/user-bundles.d. A bundle in s6-rc.d/user has priority over it, so the dev container started without nginx and php-fpm and did not become healthy. --- .../{s6-rc.d => user-bundles.d}/user/contents.d/horizon | 0 .../{s6-rc.d => user-bundles.d}/user/contents.d/init-setup | 0 .../s6-overlay/{s6-rc.d => user-bundles.d}/user/contents.d/reverb | 0 .../{s6-rc.d => user-bundles.d}/user/contents.d/scheduler-worker | 0 .../{s6-rc.d => user-bundles.d}/user/contents.d/terminal-server | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename docker/development/etc/s6-overlay/{s6-rc.d => user-bundles.d}/user/contents.d/horizon (100%) rename docker/development/etc/s6-overlay/{s6-rc.d => user-bundles.d}/user/contents.d/init-setup (100%) rename docker/development/etc/s6-overlay/{s6-rc.d => user-bundles.d}/user/contents.d/reverb (100%) rename docker/development/etc/s6-overlay/{s6-rc.d => user-bundles.d}/user/contents.d/scheduler-worker (100%) rename docker/development/etc/s6-overlay/{s6-rc.d => user-bundles.d}/user/contents.d/terminal-server (100%) diff --git a/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/horizon b/docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/horizon similarity index 100% rename from docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/horizon rename to docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/horizon diff --git a/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/init-setup b/docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/init-setup similarity index 100% rename from docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/init-setup rename to docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/init-setup diff --git a/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/reverb b/docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/reverb similarity index 100% rename from docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/reverb rename to docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/reverb diff --git a/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/scheduler-worker b/docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/scheduler-worker similarity index 100% rename from docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/scheduler-worker rename to docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/scheduler-worker diff --git a/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/terminal-server b/docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/terminal-server similarity index 100% rename from docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/terminal-server rename to docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/terminal-server From c35d3d5c8c36a20a0f14a309332b803f68c20194 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:25:33 +0200 Subject: [PATCH 048/129] fix(hostinger): use a searchable listbox for extra SSH keys --- .../livewire/server/new/by-hostinger.blade.php | 13 +++++-------- tests/Feature/HostingerServerCreationTest.php | 5 +++-- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/resources/views/livewire/server/new/by-hostinger.blade.php b/resources/views/livewire/server/new/by-hostinger.blade.php index 34010ba5d9..d33e2e4cb4 100644 --- a/resources/views/livewire/server/new/by-hostinger.blade.php +++ b/resources/views/livewire/server/new/by-hostinger.blade.php @@ -120,14 +120,11 @@
    - - @foreach ($hostinger_public_keys as $publicKey) - - @endforeach - + @if ($post_install_scripts) assertSee('Extra SSH keys') ->assertSee('Existing keys from the Hostinger account.') ->assertSee('Search SSH keys') - ->assertSeeHtml('') + ->assertSee('Operations') + ->assertSeeHtml('x-text="current">Select SSH keys') ->assertDontSeeHtml('Operations') ->assertDontSeeHtml('id="hostinger-public-key-42-'); @@ -413,7 +414,7 @@ it('labels the Hostinger status refresh button on the server page', function () Livewire::test(Show::class, ['server_uuid' => $server->uuid]) ->assertSee('Hostinger · Running') - ->assertSee('Refresh status') + ->assertSee('Refresh provider status') ->assertSeeHtml('wire:click.prevent="checkHostingerVirtualMachineStatus(true)"'); }); From f37761253d76adcd3bad049b1c2f90698ff736f4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:32:26 +0200 Subject: [PATCH 049/129] feat(server): set up Cloudflare tunnels with API tokens (#12157) --- .ai/lessons.md | 2 +- .../Development/StartDevelopmentQemuVm.php | 19 +- .../Security/IntegrationTokenEditor.php | 20 +- .../Security/IntegrationTokenForm.php | 22 +- app/Livewire/Security/IntegrationTokens.php | 1 + app/Livewire/Server/CloudflareTunnel.php | 141 +++++- app/Models/IntegrationToken.php | 39 ++ app/Models/ServerSetting.php | 3 + app/Services/CloudflareTokenValidator.php | 29 +- app/Services/CloudflareTunnelService.php | 283 +++++++++++ app/Services/Dns/CloudflareDnsProvider.php | 82 ++- config/development-qemu.php | 3 +- ...e_tunnel_api_fields_to_server_settings.php | 31 ++ .../integration-token-editor.blade.php | 12 +- .../security/integration-token-form.blade.php | 12 +- .../server/cloudflare-tunnel.blade.php | 188 ++++--- .../Feature/CloudflareTunnelApiSetupTest.php | 470 ++++++++++++++++++ tests/Feature/DevelopmentQemuVmTest.php | 28 +- .../Security/IntegrationTokenFormTest.php | 20 +- 19 files changed, 1284 insertions(+), 121 deletions(-) create mode 100644 app/Services/CloudflareTunnelService.php create mode 100644 database/migrations/2026_10_07_125311_add_cloudflare_tunnel_api_fields_to_server_settings.php create mode 100644 tests/Feature/CloudflareTunnelApiSetupTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index 03824d9748..69790b0ee1 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -12,7 +12,7 @@ - SSH retries replay the full command batch. Only exit code 255 is an SSH transport failure; other exit codes come from the remote command, so do not retry them. Keep batches safe to run twice (for example `docker rm -f || true` before `docker run --name `). - Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open. - Browser `click('Text')` can match a hidden element first (for example inactive modal steps) and wait forever; use `button:visible:has-text("Text")`. -- A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error. +- A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` on the host and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error. - Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only. - Do not symlink `vendor` into another worktree. Composer resolves the base path from the real `vendor` path, so the tests load the app classes of the other checkout. Run `composer install --ignore-platform-req=ext-zip` in the worktree. - Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`. diff --git a/app/Actions/Development/StartDevelopmentQemuVm.php b/app/Actions/Development/StartDevelopmentQemuVm.php index 52570dc42e..5815185508 100644 --- a/app/Actions/Development/StartDevelopmentQemuVm.php +++ b/app/Actions/Development/StartDevelopmentQemuVm.php @@ -73,7 +73,24 @@ class StartDevelopmentQemuVm return; } - $this->runOrFail(($state === 'paused' ? 'virsh resume ' : 'virsh start ').escapeshellarg($domain)); + if ($state === 'paused') { + $this->runOrFail('virsh resume '.escapeshellarg($domain)); + + return; + } + + $this->applyMemory($domain); + $this->runOrFail('virsh start '.escapeshellarg($domain)); + } + + /** + * Sets the configured memory on a stopped VM, so VMs created with a larger size shrink on their next start. + */ + private function applyMemory(string $domain): void + { + $memory = (int) config('development-qemu.memory').'M'; + $this->runOrFail(sprintf('virsh setmaxmem %s %s --config', escapeshellarg($domain), $memory)); + $this->runOrFail(sprintf('virsh setmem %s %s --config', escapeshellarg($domain), $memory)); } private function destroyVm(string $domain): void diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index 16a7f0ad9b..6a4c7ce2e6 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -48,15 +48,27 @@ class IntegrationTokenEditor extends Component $this->proxiedDns = $this->integrationToken->proxiedDnsEnabled(); } + /** + * Cloudflare Tunnel setup also writes DNS records, so selecting `tunnel` also selects `dns`. + */ + public function updatedCapabilities(): void + { + if (in_array('tunnel', $this->capabilities, true) && ! in_array('dns', $this->capabilities, true)) { + $this->capabilities[] = 'dns'; + } + } + protected function rules(): array { - $allowedCapability = $this->integrationToken->provider === 'cloudflare' ? 'dns' : 'secrets'; - $rules = [ 'name' => ['required', 'string', 'max:255'], 'newToken' => ['nullable', 'string'], - 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:'.$allowedCapability], + 'capabilities' => ['required', 'array', 'min:1', function (string $attribute, mixed $value, \Closure $fail): void { + if (is_array($value) && in_array('tunnel', $value, true) && ! in_array('dns', $value, true)) { + $fail('Cloudflare Tunnel also needs the DNS capability.'); + } + }], + 'capabilities.*' => ['required', 'in:'.implode(',', IntegrationToken::allowedCapabilities($this->integrationToken->provider))], 'automaticDns' => ['boolean'], 'proxiedDns' => ['boolean'], ]; diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php index 3cfbc75aa7..54ce0b704e 100644 --- a/app/Livewire/Security/IntegrationTokenForm.php +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -49,16 +49,28 @@ class IntegrationTokenForm extends Component } } + /** + * Cloudflare Tunnel setup also writes DNS records, so selecting `tunnel` also selects `dns`. + */ + public function updatedCapabilities(): void + { + if (in_array('tunnel', $this->capabilities, true) && ! in_array('dns', $this->capabilities, true)) { + $this->capabilities[] = 'dns'; + } + } + protected function rules(): array { - $allowedCapability = $this->provider === 'cloudflare' ? 'dns' : 'secrets'; - $rules = [ 'provider' => ['required', 'in:'.implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))], 'name' => ['required', 'string', 'max:255'], 'token' => ['required', 'string'], - 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:'.$allowedCapability], + 'capabilities' => ['required', 'array', 'min:1', function (string $attribute, mixed $value, \Closure $fail): void { + if (is_array($value) && in_array('tunnel', $value, true) && ! in_array('dns', $value, true)) { + $fail('Cloudflare Tunnel also needs the DNS capability.'); + } + }], + 'capabilities.*' => ['required', 'in:'.implode(',', IntegrationToken::allowedCapabilities($this->provider))], 'automaticDns' => ['boolean'], 'proxiedDns' => ['boolean'], ]; @@ -129,7 +141,7 @@ class IntegrationTokenForm extends Component ]); $this->reset(['name', 'token']); - $this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class); + $this->dispatch('integrationTokenAdded'); if ($this->modal_mode) { $this->dispatch('close-modal'); diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php index 34b2b38a07..0b02593726 100644 --- a/app/Livewire/Security/IntegrationTokens.php +++ b/app/Livewire/Security/IntegrationTokens.php @@ -20,6 +20,7 @@ class IntegrationTokens extends Component } #[On('integrationTokenAdded')] + #[On('integration-token-deleted')] public function loadTokens(): void { $this->tokens = IntegrationToken::ownedByCurrentTeam()->withCount('dnsZones')->latest()->get(); diff --git a/app/Livewire/Server/CloudflareTunnel.php b/app/Livewire/Server/CloudflareTunnel.php index bf0172a6d9..31bca417f6 100644 --- a/app/Livewire/Server/CloudflareTunnel.php +++ b/app/Livewire/Server/CloudflareTunnel.php @@ -4,9 +4,17 @@ namespace App\Livewire\Server; use App\Actions\Server\ConfigureCloudflared; use App\Actions\Server\UpdateCloudflared; +use App\Exceptions\DnsRecordConflictException; +use App\Models\IntegrationToken; use App\Models\Server; +use App\Rules\ValidHostname; +use App\Services\CloudflareTunnelService; use App\Traits\ListensToTeamChannel; +use Illuminate\Contracts\Cache\LockTimeoutException; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Locked; +use Livewire\Attributes\On; use Livewire\Attributes\Validate; use Livewire\Component; @@ -26,6 +34,18 @@ class CloudflareTunnel extends Component #[Validate(['required', 'boolean'])] public bool $isCloudflareTunnelsEnabled; + public ?int $cloudflare_integration_token_id = null; + + public string $api_ssh_domain = ''; + + /** + * An existing DNS record on the hostname that blocks the tunnel CNAME, waiting for the user to confirm its replacement. + * + * @var array{record_id: string, current: string, hostname: string, token_id: int}|null + */ + #[Locked] + public ?array $dnsConflict = null; + public function getListeners() { return $this->teamChannelListeners([ @@ -33,6 +53,18 @@ class CloudflareTunnel extends Component ]); } + /** + * Selects the token that the user just added from this page, when it is the only one that can manage tunnels. + */ + #[On('integrationTokenAdded')] + public function selectAddedToken(): void + { + $tunnelTokens = $this->tunnelTokens(); + if ($this->cloudflare_integration_token_id === null && $tunnelTokens->count() === 1) { + $this->cloudflare_integration_token_id = $tunnelTokens->first()->id; + } + } + public function refresh() { $this->server->refresh(); @@ -52,11 +84,17 @@ class CloudflareTunnel extends Component } } - public function toggleCloudflareTunnels() + public function toggleCloudflareTunnels(CloudflareTunnelService $tunnels) { try { $this->authorize('update', $this->server); - remote_process(['docker rm -f coolify-cloudflared'], $this->server, false, 10); + if (filled($this->server->settings->cloudflare_tunnel_id)) { + foreach ($tunnels->tearDown($this->server) as $warning) { + $this->dispatch('warning', $warning); + } + } else { + remote_process(['docker rm -f coolify-cloudflared'], $this->server, false, 10); + } $this->isCloudflareTunnelsEnabled = false; $this->server->settings->is_cloudflare_tunnel = false; $this->server->settings->save(); @@ -91,10 +129,7 @@ class CloudflareTunnel extends Component { try { $this->authorize('update', $this->server); - if (str($this->ssh_domain)->contains('https://')) { - $this->ssh_domain = str($this->ssh_domain)->replace('https://', '')->replace('http://', '')->trim(); - $this->ssh_domain = str($this->ssh_domain)->replace('/', ''); - } + $this->ssh_domain = $this->normalizeSshDomain($this->ssh_domain); $activity = ConfigureCloudflared::run($this->server, $this->cloudflare_token, $this->ssh_domain); auditLog('ui.server.cloudflare_tunnel.configuration_started', $this->auditContext([ 'ssh_domain' => (string) $this->ssh_domain, @@ -118,9 +153,101 @@ class CloudflareTunnel extends Component } } + public function apiCloudflareConfig(CloudflareTunnelService $tunnels): void + { + $this->authorize('update', $this->server); + $this->api_ssh_domain = $this->normalizeSshDomain($this->api_ssh_domain); + $this->validate([ + 'cloudflare_integration_token_id' => ['required', 'integer'], + 'api_ssh_domain' => ['required', 'string', new ValidHostname], + ], [], ['cloudflare_integration_token_id' => 'Cloudflare token', 'api_ssh_domain' => 'SSH hostname']); + + $token = $this->tunnelTokens()->firstWhere('id', $this->cloudflare_integration_token_id); + if ($token === null) { + $this->addError('cloudflare_integration_token_id', 'Select a Cloudflare token of this server team with the Cloudflare Tunnel capability.'); + + return; + } + + $this->dnsConflict = null; + $this->startApiSetup($tunnels, $token, $this->api_ssh_domain); + } + + public function replaceDnsAndConfigure(CloudflareTunnelService $tunnels): void + { + $this->authorize('update', $this->server); + $conflict = $this->dnsConflict; + $token = $conflict === null ? null : $this->tunnelTokens()->firstWhere('id', $conflict['token_id']); + if ($conflict === null || $token === null) { + $this->dnsConflict = null; + $this->dispatch('error', 'The DNS conflict is no longer available. Set up the tunnel again.'); + + return; + } + + $this->dnsConflict = null; + $this->startApiSetup($tunnels, $token, $conflict['hostname'], $conflict); + } + public function render() { - return view('livewire.server.cloudflare-tunnel'); + $cloudflareTokens = IntegrationToken::query() + ->where('team_id', $this->server->team_id) + ->where('provider', 'cloudflare') + ->count(); + + return view('livewire.server.cloudflare-tunnel', [ + 'tunnelTokens' => $this->tunnelTokens(), + 'hasCloudflareTokens' => $cloudflareTokens > 0, + ]); + } + + /** + * Cloudflare tokens of the server's team (not the session team) that can manage tunnels. + * + * @return Collection + */ + private function tunnelTokens(): Collection + { + return IntegrationToken::query() + ->where('team_id', $this->server->team_id) + ->where('provider', 'cloudflare') + ->orderBy('name') + ->get() + ->filter(fn (IntegrationToken $token): bool => $token->hasCapability('tunnel')) + ->values(); + } + + /** + * @param array{record_id: string, current: string}|null $confirmedConflict + */ + private function startApiSetup(CloudflareTunnelService $tunnels, IntegrationToken $token, string $hostname, ?array $confirmedConflict = null): void + { + try { + $activity = $tunnels->setUp($this->server, $token, $hostname, $confirmedConflict); + auditLog('ui.server.cloudflare_tunnel.api_configuration_started', $this->auditContext([ + 'ssh_domain' => $hostname, + 'integration_token_uuid' => $token->uuid, + ])); + $this->dispatch('automated'); + $this->dispatch('activityMonitor', $activity->id); + } catch (DnsRecordConflictException $e) { + $this->dnsConflict = [ + 'record_id' => $e->providerRecordId, + 'current' => $e->currentValue, + 'hostname' => $hostname, + 'token_id' => $token->id, + ]; + } catch (LockTimeoutException) { + $this->dispatch('error', "Another DNS change for {$hostname} is in progress. Try again in a few seconds."); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + private function normalizeSshDomain(string $domain): string + { + return str($domain)->replace(['https://', 'http://'], '')->replace('/', '')->trim()->lower()->value(); } /** diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php index 119c8d95df..05f8174c70 100644 --- a/app/Models/IntegrationToken.php +++ b/app/Models/IntegrationToken.php @@ -71,6 +71,45 @@ class IntegrationToken extends BaseModel return self::PROVIDER_NAMES[$this->provider] ?? ucfirst($this->provider); } + /** + * Capabilities a token of the provider can have. Cloudflare Tunnel setup also writes DNS records, so `tunnel` needs `dns`. + * + * @return array + */ + public static function allowedCapabilities(string $provider): array + { + return $provider === 'cloudflare' ? ['dns', 'tunnel'] : ['secrets']; + } + + /** + * Cloudflare dashboard link that pre-fills a user API token with the permissions the capabilities need. + * + * @param array $capabilities + */ + public static function cloudflareTokenTemplateUrl(array $capabilities): string + { + $permissions = [ + ['key' => 'dns', 'type' => 'edit'], + ['key' => 'zone', 'type' => 'read'], + ]; + if (in_array('tunnel', $capabilities, true)) { + // `argotunnel` is Cloudflare's internal key for the "Cloudflare Tunnel" permission group. + $permissions[] = ['key' => 'argotunnel', 'type' => 'edit']; + } + + return 'https://dash.cloudflare.com/profile/api-tokens?'.http_build_query([ + 'permissionGroupKeys' => json_encode($permissions), + 'accountId' => '*', + 'zoneId' => 'all', + 'name' => in_array('tunnel', $capabilities, true) ? 'Coolify DNS and Tunnel Management' : 'Coolify DNS Management', + ], '', '&', PHP_QUERY_RFC3986); + } + + public function hasCapability(string $capability): bool + { + return in_array($capability, $this->capabilities ?? [], true); + } + public function automaticDnsEnabled(): bool { return $this->provider === 'cloudflare' && data_get($this->metadata, 'automatic_dns', true) !== false; diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 79b541a328..f2c6116c06 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -98,6 +98,9 @@ class ServerSetting extends Model 'is_usable', 'wildcard_domain', 'is_cloudflare_tunnel', + 'cloudflare_tunnel_id', + 'cloudflare_tunnel_account_id', + 'cloudflare_tunnel_integration_token_id', 'is_logdrain_newrelic_enabled', 'logdrain_newrelic_license_key', 'logdrain_newrelic_base_uri', diff --git a/app/Services/CloudflareTokenValidator.php b/app/Services/CloudflareTokenValidator.php index 2a4a761027..a48efda741 100644 --- a/app/Services/CloudflareTokenValidator.php +++ b/app/Services/CloudflareTokenValidator.php @@ -16,17 +16,28 @@ class CloudflareTokenValidator return false; } - if (in_array('dns', $capabilities, true)) { - $zones = $client->get('https://api.cloudflare.com/client/v4/zones', ['per_page' => 1]); - $zoneId = $zones->json('result.0.id'); + if (! in_array('dns', $capabilities, true)) { + return true; + } - if (! $zones->successful() || ! is_string($zoneId)) { - return false; - } + $zones = $client->get('https://api.cloudflare.com/client/v4/zones', ['per_page' => 1]); + $zoneId = $zones->json('result.0.id'); - return $client->get("https://api.cloudflare.com/client/v4/zones/{$zoneId}/dns_records", [ - 'per_page' => 1, - ])->successful(); + if (! $zones->successful() || ! is_string($zoneId)) { + return false; + } + + if (! $client->get("https://api.cloudflare.com/client/v4/zones/{$zoneId}/dns_records", ['per_page' => 1])->successful()) { + return false; + } + + if (in_array('tunnel', $capabilities, true)) { + $accountId = $zones->json('result.0.account.id'); + + return is_string($accountId) && $client->get( + "https://api.cloudflare.com/client/v4/accounts/{$accountId}/cfd_tunnel", + ['per_page' => 1, 'is_deleted' => 'false'], + )->successful(); } return true; diff --git a/app/Services/CloudflareTunnelService.php b/app/Services/CloudflareTunnelService.php new file mode 100644 index 0000000000..358b4075d4 --- /dev/null +++ b/app/Services/CloudflareTunnelService.php @@ -0,0 +1,283 @@ +uuid}"; + } + + /** + * Creates (or reuses) the tunnel, routes the hostname to the server SSH port, points a proxied CNAME at the tunnel, + * and starts cloudflared. The CloudflareTunnelChanged listener enables the tunnel when cloudflared is healthy. + * + * @param array{record_id: string, current: string}|null $confirmedConflict An existing DNS record the user agreed to replace. + * + * @throws DnsRecordConflictException when another DNS record already uses the hostname + * @throws LockTimeoutException when another DNS change for the hostname does not finish in time + * @throws RuntimeException when a Cloudflare call fails + */ + public function setUp(Server $server, IntegrationToken $token, string $hostname, ?array $confirmedConflict = null): Activity + { + $zone = $this->zoneFor($server, $token, $hostname); + $accountId = $zone->account_id; + if (blank($accountId)) { + throw new RuntimeException("Cloudflare returned no account for the zone {$zone->name}."); + } + + [$tunnelId, $tunnelToken, $created] = $this->findOrCreateTunnel($token, $accountId, self::tunnelName($server)); + + try { + $this->request($token, 'put', "/accounts/{$accountId}/cfd_tunnel/{$tunnelId}/configurations", [ + 'config' => ['ingress' => [ + ['hostname' => $hostname, 'service' => "ssh://localhost:{$server->port}"], + ['service' => 'http_status:404'], + ]], + ]); + + $target = "{$tunnelId}.cfargotunnel.com"; + if ($confirmedConflict !== null) { + $this->dns->replaceRecord($zone, $confirmedConflict['record_id'], $hostname, $target, $server, $confirmedConflict['current'], proxied: true); + } else { + $this->dns->createRecord($zone, $hostname, $target, $server, proxied: true); + } + } catch (Throwable $e) { + // Coolify does not track the tunnel yet, so a tunnel created by this run would stay in Cloudflare. + if ($created) { + $this->deleteUntrackedTunnel($token, $accountId, $tunnelId); + } + + throw $e; + } + $this->releaseDnsRecords($server, except: $hostname); + + // Saved before cloudflared starts, so disabling the tunnel can clean up even when cloudflared does not become healthy. + $server->settings->update([ + 'cloudflare_tunnel_id' => $tunnelId, + 'cloudflare_tunnel_account_id' => $accountId, + 'cloudflare_tunnel_integration_token_id' => $token->id, + ]); + + return ConfigureCloudflared::run($server, $tunnelToken, $hostname); + } + + /** + * Stops cloudflared and deletes the tunnel and the DNS record that setUp() created. + * Never throws; returns warnings for the user and always forgets the tunnel. + * + * @return array + */ + public function tearDown(Server $server): array + { + $settings = $server->settings; + if (blank($settings->cloudflare_tunnel_id)) { + return []; + } + + $warnings = []; + $name = self::tunnelName($server); + $token = IntegrationToken::query() + ->where('team_id', $server->team_id) + ->whereKey($settings->cloudflare_tunnel_integration_token_id) + ->first(); + + // Coolify usually reaches the server through this tunnel. Stopping cloudflared in the SSH session would cut + // that session and make SSH retry, so the stop runs detached and the session ends first. + instant_remote_process([ + "nohup setsid sh -c 'sleep 2; docker rm -f coolify-cloudflared' > /dev/null 2>&1 < /dev/null &", + ], $server, false); + + // The DNS record does not depend on the connector, so it goes first while cloudflared stops. + array_push($warnings, ...$this->releaseDnsRecords($server)); + + if ($token === null) { + $warnings[] = "The Cloudflare token of the tunnel no longer exists. Delete the tunnel {$name} in Cloudflare."; + } elseif (($warning = $this->deleteStoppedTunnel($token, $settings->cloudflare_tunnel_account_id, $settings->cloudflare_tunnel_id, $name)) !== null) { + $warnings[] = $warning; + } + + $settings->update([ + 'cloudflare_tunnel_id' => null, + 'cloudflare_tunnel_account_id' => null, + 'cloudflare_tunnel_integration_token_id' => null, + ]); + + return $warnings; + } + + /** + * Deletes the tunnel once its connector is gone. Removing the connections first lets Cloudflare forget a connector + * that already stopped; a connector that is still stopping keeps the delete failing until it disconnects. + */ + private function deleteStoppedTunnel(IntegrationToken $token, string $accountId, string $tunnelId, string $name): ?string + { + $path = self::API_URL."/accounts/{$accountId}/cfd_tunnel/{$tunnelId}"; + + try { + for ($attempt = 1; $attempt <= self::DELETE_ATTEMPTS; $attempt++) { + $this->client($token)->delete("{$path}/connections"); + $response = $this->client($token)->delete($path); + if ($response->successful() || $response->status() === 404) { + return null; + } + if ($response->status() === 403) { + return CloudflareDnsProvider::missingPermissionMessage(self::TUNNEL_PERMISSION)." Delete the tunnel {$name} in Cloudflare."; + } + if ($attempt < self::DELETE_ATTEMPTS) { + Sleep::for(2)->seconds(); + } + } + } catch (Throwable) { + return "Cloudflare did not respond. Delete the tunnel {$name} in Cloudflare."; + } + + return "Cloudflare could not delete the tunnel {$name}. Delete it in Cloudflare."; + } + + private function zoneFor(Server $server, IntegrationToken $token, string $hostname): DnsProviderZone + { + $find = fn (): ?DnsProviderZone => $this->dns->findZones($server->team_id, $hostname) + ->firstWhere('integration_token_id', $token->id); + + $zone = $find(); + if ($zone === null) { + $this->dns->syncZones($token); + $zone = $find(); + } + + return $zone ?? throw new RuntimeException("No Cloudflare zone of the token {$token->name} manages {$hostname}."); + } + + /** + * @return array{0: string, 1: string, 2: bool} tunnel ID, tunnel token, and whether this call created the tunnel + */ + private function findOrCreateTunnel(IntegrationToken $token, string $accountId, string $name): array + { + $existing = collect($this->request($token, 'get', "/accounts/{$accountId}/cfd_tunnel", [ + 'name' => $name, + 'is_deleted' => 'false', + ])->json('result', []))->first(fn ($tunnel): bool => is_array($tunnel) && ($tunnel['name'] ?? null) === $name); + + if (is_string($existing['id'] ?? null)) { + $tunnelId = $existing['id']; + $tunnelToken = $this->request($token, 'get', "/accounts/{$accountId}/cfd_tunnel/{$tunnelId}/token")->json('result'); + $created = false; + } else { + $response = $this->request($token, 'post', "/accounts/{$accountId}/cfd_tunnel", [ + 'name' => $name, + 'config_src' => 'cloudflare', + ]); + $tunnelId = $response->json('result.id'); + $tunnelToken = $response->json('result.token'); + $created = true; + } + + if (! is_string($tunnelId) || ! is_string($tunnelToken) || $tunnelToken === '') { + throw new RuntimeException('Cloudflare returned no tunnel token.'); + } + + return [$tunnelId, $tunnelToken, $created]; + } + + /** + * Best effort: the tunnel has no connector yet, so Cloudflare deletes it directly. + */ + private function deleteUntrackedTunnel(IntegrationToken $token, string $accountId, string $tunnelId): void + { + try { + $this->client($token)->delete(self::API_URL."/accounts/{$accountId}/cfd_tunnel/{$tunnelId}"); + } catch (Throwable) { + // The original error matters more; a re-run reuses the tunnel by name anyway. + } + } + + /** + * Releases the server's managed DNS records (except the given hostname). Records are deleted only when + * Coolify created them and nothing else uses the hostname. + * + * @return array + */ + private function releaseDnsRecords(Server $server, ?string $except = null): array + { + $key = [$server->getMorphClass(), $server->getKey()]; + $records = ManagedDnsRecord::query() + ->where('team_id', $server->team_id) + ->when($except !== null, fn ($query) => $query->where('name', '!=', $except)) + ->whereHas('references', fn ($query) => $query->where('resource_type', $key[0])->where('resource_id', $key[1])) + ->get(); + + return $records->map(function (ManagedDnsRecord $record) use ($key): ?string { + try { + $result = $this->cleanup->release($record, [$key], true); + } catch (Throwable) { + $result = ManagedDnsDeletionResult::Failed; + } + + return match ($result) { + ManagedDnsDeletionResult::ChangedExternally => "The DNS record {$record->name} changed outside Coolify and was left untouched.", + ManagedDnsDeletionResult::Failed, ManagedDnsDeletionResult::Busy => "The DNS record {$record->name} could not be deleted. Delete it in Cloudflare.", + default => null, + }; + })->filter()->values()->all(); + } + + /** + * @param array $data + */ + private function request(IntegrationToken $token, string $method, string $path, array $data = []): Response + { + $response = $this->client($token)->{$method}(self::API_URL.$path, $data); + + if ($response->status() === 403) { + throw new RuntimeException(CloudflareDnsProvider::missingPermissionMessage(self::TUNNEL_PERMISSION)); + } + if (! $response->successful() || $response->json('success') === false) { + $message = $response->json('errors.0.message'); + + throw new RuntimeException('Cloudflare Tunnel request failed'.(is_string($message) ? ": {$message}" : '.')); + } + + return $response; + } + + private function client(IntegrationToken $token): PendingRequest + { + return Http::withToken($token->token)->acceptJson()->connectTimeout(5)->timeout(15); + } +} diff --git a/app/Services/Dns/CloudflareDnsProvider.php b/app/Services/Dns/CloudflareDnsProvider.php index 995a632f8a..d3c57b78b5 100644 --- a/app/Services/Dns/CloudflareDnsProvider.php +++ b/app/Services/Dns/CloudflareDnsProvider.php @@ -31,6 +31,9 @@ class CloudflareDnsProvider $page = 1; do { $response = $this->client($token)->get('https://api.cloudflare.com/client/v4/zones', ['page' => $page, 'per_page' => 50]); + if ($response->status() === 403) { + throw new RuntimeException(self::missingPermissionMessage('Zone → Zone → Read')); + } if (! $response->successful() || $response->json('success') !== true) { throw new RuntimeException('Cloudflare zones could not be synchronized.'); } @@ -78,29 +81,54 @@ class CloudflareDnsProvider ->get(); } + public static function missingPermissionMessage(string $permission): string + { + return "The Cloudflare token needs the {$permission} permission."; + } + /** - * All records of the given type for a hostname (several records form a round-robin set). + * All records of the given type (all types when null) for a hostname (several records form a round-robin set). * * @return array */ - public function findRecords(DnsProviderZone $zone, string $hostname, string $type): array + public function findRecords(DnsProviderZone $zone, string $hostname, ?string $type): array { $hostname = strtolower(rtrim($hostname, '.')); $response = $this->client($zone->integrationToken)->get( "https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", - ['type' => $type, 'name' => $hostname, 'per_page' => 100], + array_filter(['type' => $type, 'name' => $hostname, 'per_page' => 100]), ); + if ($response->status() === 403) { + throw new RuntimeException(self::missingPermissionMessage('Zone → DNS → Edit')); + } if (! $response->successful()) { throw new RuntimeException('Cloudflare DNS records could not be checked.'); } return collect($response->json('result', [])) ->filter(fn ($remote): bool => is_array($remote)) - ->map(fn (array $remote): array => $this->normalizeRemoteRecord($remote, $hostname, $type)) + ->map(fn (array $remote): array => $this->normalizeRemoteRecord($remote, $hostname, $type ?? '')) ->values() ->all(); } + /** + * Records that block a new record of the type. A CNAME cannot share its name with A, AAAA, or other CNAME records. + * + * @return array + */ + private function conflictingRecords(DnsProviderZone $zone, string $hostname, string $type): array + { + if ($type !== 'CNAME') { + return $this->findRecords($zone, $hostname, $type); + } + + return array_values(array_filter( + $this->findRecords($zone, $hostname, null), + fn (array $remote): bool => in_array($remote['type'], ['A', 'AAAA', 'CNAME'], true), + )); + } + /** * Creates the record with Coolify's ownership comment, or references an existing record that already has the wanted content. * Existing records that Coolify did not create are only referenced and never marked as owned. @@ -111,15 +139,15 @@ class CloudflareDnsProvider * * @throws LockTimeoutException when another change for the hostname does not finish in time */ - public function createRecord(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource = null, array $additionalResources = []): ManagedDnsRecord + public function createRecord(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource = null, array $additionalResources = [], ?bool $proxied = null): ManagedDnsRecord { $hostname = strtolower(rtrim($hostname, '.')); return $this->hostnameLock->run( (int) $zone->integrationToken->team_id, $hostname, - function () use ($zone, $hostname, $content, $resource, $additionalResources): ManagedDnsRecord { - $record = $this->createRecordLocked($zone, $hostname, $content, $resource); + function () use ($zone, $hostname, $content, $resource, $additionalResources, $proxied): ManagedDnsRecord { + $record = $this->createRecordLocked($zone, $hostname, $content, $resource, $proxied); foreach ($additionalResources as $additionalResource) { $record->addReference($additionalResource); } @@ -129,11 +157,11 @@ class CloudflareDnsProvider ); } - private function createRecordLocked(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource): ManagedDnsRecord + private function createRecordLocked(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource, ?bool $proxied): ManagedDnsRecord { $type = $this->recordType($content); - $remoteRecords = $this->findRecords($zone, $hostname, $type); - $matching = collect($remoteRecords)->first(fn (array $remote): bool => DnsRecordHints::sameAddress($remote['content'], $content)); + $remoteRecords = $this->conflictingRecords($zone, $hostname, $type); + $matching = collect($remoteRecords)->first(fn (array $remote): bool => $remote['type'] === $type && DnsRecordHints::sameAddress($remote['content'], $content)); if ($matching !== null) { if ($matching['id'] === '') { throw new RuntimeException('Cloudflare DNS records could not be checked.'); @@ -154,9 +182,12 @@ class CloudflareDnsProvider $uuid = new_public_id(); $response = $this->client($zone->integrationToken)->post("https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", [ 'type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, - 'proxied' => $zone->integrationToken->proxiedDnsEnabled(), + 'proxied' => $proxied ?? $zone->integrationToken->proxiedDnsEnabled(), 'comment' => ManagedDnsRecord::ownershipCommentFor($uuid), ]); + if ($response->status() === 403) { + throw new RuntimeException(self::missingPermissionMessage('Zone → DNS → Edit')); + } if (! $response->successful() || ! is_string($response->json('result.id'))) { throw new RuntimeException('Cloudflare could not create the DNS record.'); } @@ -176,7 +207,8 @@ class CloudflareDnsProvider /** * Points an existing (conflicting) record at the new content after explicit user confirmation. - * Only the content changes: proxy status, TTL, comment and other settings stay as they are. + * Only the content (and the type, when it differs) changes: TTL, comment and other settings stay as they are. + * The proxy status changes only when $proxied is set. * * @param array $additionalResources Other resources that use the hostname and also reference the record. * @@ -190,14 +222,15 @@ class CloudflareDnsProvider ?Model $resource = null, ?string $expectedCurrent = null, array $additionalResources = [], + bool $proxied = false, ): ManagedDnsRecord { $hostname = strtolower(rtrim($hostname, '.')); return $this->hostnameLock->run( (int) $zone->integrationToken->team_id, $hostname, - function () use ($zone, $recordId, $hostname, $content, $resource, $expectedCurrent, $additionalResources): ManagedDnsRecord { - $record = $this->replaceRecordLocked($zone, $recordId, $hostname, $content, $resource, $expectedCurrent); + function () use ($zone, $recordId, $hostname, $content, $resource, $expectedCurrent, $additionalResources, $proxied): ManagedDnsRecord { + $record = $this->replaceRecordLocked($zone, $recordId, $hostname, $content, $resource, $expectedCurrent, $proxied); foreach ($additionalResources as $additionalResource) { $record->addReference($additionalResource); } @@ -214,9 +247,10 @@ class CloudflareDnsProvider string $content, ?Model $resource, ?string $expectedCurrent, + bool $proxied, ): ManagedDnsRecord { $type = $this->recordType($content); - $remoteRecords = $this->findRecords($zone, $hostname, $type); + $remoteRecords = $this->conflictingRecords($zone, $hostname, $type); if (count($remoteRecords) > 1) { throw new RuntimeException("Several DNS records already exist for {$hostname}. Update them in Cloudflare."); } @@ -229,10 +263,20 @@ class CloudflareDnsProvider throw new RuntimeException('The DNS conflict is no longer available. Check the record again.'); } + $changes = ['content' => $content]; + if ($remote['type'] !== $type) { + $changes['type'] = $type; + } + if ($proxied) { + $changes['proxied'] = true; + } $response = $this->client($zone->integrationToken)->patch( "https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records/{$remote['id']}", - ['content' => $content], + $changes, ); + if ($response->status() === 403) { + throw new RuntimeException(self::missingPermissionMessage('Zone → DNS → Edit')); + } if (! $response->successful()) { throw new RuntimeException('Cloudflare could not replace the conflicting DNS record.'); } @@ -337,7 +381,11 @@ class CloudflareDnsProvider private function recordType(string $content): string { - return filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'AAAA' : 'A'; + return match (true) { + filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false => 'AAAA', + filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false => 'A', + default => 'CNAME', + }; } private function auditDnsRecord(string $action, DnsProviderZone $zone, string $hostname, ?Model $resource): void diff --git a/config/development-qemu.php b/config/development-qemu.php index ed93362258..983cd587aa 100644 --- a/config/development-qemu.php +++ b/config/development-qemu.php @@ -145,7 +145,8 @@ return [ 'subnet' => $developmentQemuInstance === null ? '192.168.122.0/24' : "10.221.{$developmentQemuSlot}.0/24", 'prefix' => 24, 'dns' => '1.1.1.1', - 'memory' => 2048, + // MiB per VM. Many dev instances share one host, so keep this small; existing VMs get it on their next start. + 'memory' => (int) env('DEVELOPMENT_QEMU_MEMORY', 1024), 'vcpus' => 2, 'disk_size' => '20G', 'libvirt_network' => $developmentQemuInstance === null ? 'default' : "coolify-dev-{$developmentQemuSlot}", diff --git a/database/migrations/2026_10_07_125311_add_cloudflare_tunnel_api_fields_to_server_settings.php b/database/migrations/2026_10_07_125311_add_cloudflare_tunnel_api_fields_to_server_settings.php new file mode 100644 index 0000000000..41cf9e0094 --- /dev/null +++ b/database/migrations/2026_10_07_125311_add_cloudflare_tunnel_api_fields_to_server_settings.php @@ -0,0 +1,31 @@ +string('cloudflare_tunnel_id')->nullable(); + $table->string('cloudflare_tunnel_account_id')->nullable(); + $table->foreignId('cloudflare_tunnel_integration_token_id')->nullable()->constrained('integration_tokens')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropConstrainedForeignId('cloudflare_tunnel_integration_token_id'); + $table->dropColumn(['cloudflare_tunnel_id', 'cloudflare_tunnel_account_id']); + }); + } +}; diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php index ed7d108aed..46882ba513 100644 --- a/resources/views/livewire/security/integration-token-editor.blade.php +++ b/resources/views/livewire/security/integration-token-editor.blade.php @@ -34,10 +34,15 @@ Capabilities
    + :disabled="in_array('tunnel', $capabilities, true)" wire:model.live="capabilities" />

    Manage Cloudflare DNS records.

    + +

    + Create and manage tunnels for server SSH. Needs DNS. +

    @error('capabilities') {{ $message }} @@ -71,8 +76,11 @@
    • Zone - DNS - Edit
    • Zone - Zone - Read
    • + @if (in_array('tunnel', $capabilities, true)) +
    • Account - Cloudflare Tunnel - Edit
    • + @endif
    -
    Create a replacement token in Cloudflare diff --git a/resources/views/livewire/security/integration-token-form.blade.php b/resources/views/livewire/security/integration-token-form.blade.php index a085ebbe1a..004c642f03 100644 --- a/resources/views/livewire/security/integration-token-form.blade.php +++ b/resources/views/livewire/security/integration-token-form.blade.php @@ -42,10 +42,15 @@ Capabilities
    + :disabled="in_array('tunnel', $capabilities, true)" wire:model.live="capabilities" />

    Manage Cloudflare DNS records.

    + +

    + Create and manage tunnels for server SSH. Needs DNS. +

    @error('capabilities') {{ $message }} @@ -79,9 +84,12 @@
    • Zone - DNS - Edit
    • Zone - Zone - Read
    • + @if (in_array('tunnel', $capabilities, true)) +
    • Account - Cloudflare Tunnel - Edit
    • + @endif

    Limit zone resources to the zones Coolify should manage.

    -
    Create token at Cloudflare diff --git a/resources/views/livewire/server/cloudflare-tunnel.blade.php b/resources/views/livewire/server/cloudflare-tunnel.blade.php index 255260ca18..7ae00efde6 100644 --- a/resources/views/livewire/server/cloudflare-tunnel.blade.php +++ b/resources/views/livewire/server/cloudflare-tunnel.blade.php @@ -22,31 +22,60 @@ - + Documentation + + + @endif + @if (!$isCloudflareTunnelsEnabled && $server->isFunctional() && auth()->user()->can('update', $server)) + - @else - + @endif + @if ($isCloudflareTunnelsEnabled) + @endif @if ($isCloudflareTunnelsEnabled) + @if (filled($server->settings->cloudflare_tunnel_id)) +

    + Coolify manages this tunnel through the Cloudflare API (tunnel + {{ \App\Services\CloudflareTunnelService::tunnelName($server) }}). +

    + @endif The server IP must be restored to its direct address after disabling the tunnel. + @can('update', $server) +
    + +
    + @endcan @elseif (!$server->isFunctional()) Automated configuration requires a validated server, a Cloudflare token, and an SSH domain. @@ -56,25 +85,6 @@ . @else -

    - Choose automated setup to install and configure cloudflared, or confirm that you already - configured the tunnel manually. -

    - @endif - - - @if (!$isCloudflareTunnelsEnabled && $server->isFunctional()) - - - - Documentation - - - - @cannot('update', $server) You do not have permission to configure Cloudflare Tunnel for this server. @@ -86,34 +96,88 @@ -
    -
    - - -
    -
    - Configure tunnel -
    -
    + + @if ($tunnelTokens->isEmpty()) + + @if ($hasCloudflareTokens) + Enable the Cloudflare Tunnel capability on a Cloudflare token + @else + Add a Cloudflare token with the DNS and Cloudflare Tunnel capabilities + @endif + in + Integration tokens. + The token needs Account - Cloudflare Tunnel - Edit, Zone - DNS - Edit, and Zone - Zone - Read. + + @can('create', App\Models\IntegrationToken::class) +
    + + + + + + +
    + @endcan + @else +
    +
    + + +
    +
    + Set up tunnel +
    +
    + @if ($dnsConflict) +
    + + {{ $dnsConflict['hostname'] }} already points to {{ $dnsConflict['current'] }}. + Replace it with the tunnel record to continue. + +
    + +
    +
    + @endif + @endif + +
    + +

    + For a tunnel you created in the Cloudflare dashboard. Coolify does not need a + Cloudflare API token for this. +

    +
    +
    + + +
    +
    + Configure tunnel +
    +
    +
    +
    @endcannot -
    - - - @can('update', $server) - - @endcan - + @endif + + @if (!$isCloudflareTunnelsEnabled && $server->isFunctional()) @script + +
    + +
    +
    + {#if cv.target} + + + {/if} + Deploy canvas + Beta +
    +
    + +
    + + + + +
    + + +
    +
    + + {#if noticeOpen} +
    + +

    The canvas is not optimized for mobile yet. It works best on a larger screen.{#if cv.target}{' On a phone, use the '}New resource page.{/if}

    + +
    + {/if} + + + +
    +
    zoomLabel}> +
    + + {#each cv.flow.nodes as n (n.id)} + + {/each} + {#if resultNode} + +
    + + + {#if cv.result.urls.length} + {#each cv.result.urls.slice(0, 3) as url} + {url.replace(/^https?:\/\//, '')} + {/each} + {:else} + Open in Coolify + {/if} + +
    + {/if} +
    +
    + + {#if cv.menu} +
    +
    Add the next step
    + {#each cv.menu.valid as type} + pickNext(type)} /> + {/each} +
    + {/if} + {#if cv.toast} +
    {cv.toast.text}
    + {/if} +
    + + {#if cv.dropdown} + {#key cv.dropdown?.trigger} + + {/key} + {/if} +
    diff --git a/resources/js/canvas/deploy/Dropdown.svelte b/resources/js/canvas/deploy/Dropdown.svelte new file mode 100644 index 0000000000..8806ded1a5 --- /dev/null +++ b/resources/js/canvas/deploy/Dropdown.svelte @@ -0,0 +1,137 @@ + + +
    + {#if items.length > 8} + (active = 0)} /> + {/if} +
    + {#each shown as [value, text, badge], i} + + {:else} +

    {q ? 'No match' : 'None yet'}

    + {/each} +
    + {#if add} +
    + {#if adding} +
    + + { if (e.key !== 'Escape') e.stopPropagation(); }} + /> + +
    +

    {problem}

    + {:else} + + {/if} +
    + {/if} +
    diff --git a/resources/js/canvas/deploy/Edges.svelte b/resources/js/canvas/deploy/Edges.svelte new file mode 100644 index 0000000000..8eac07e5f6 --- /dev/null +++ b/resources/js/canvas/deploy/Edges.svelte @@ -0,0 +1,48 @@ + + + + {#each lines as line} + {#if line.edge} + + + removeEdge(line.edge)}>Click to remove this connection + {/if} + + {/each} + {#if dragged} + + {/if} + diff --git a/resources/js/canvas/deploy/Field.svelte b/resources/js/canvas/deploy/Field.svelte new file mode 100644 index 0000000000..293457c860 --- /dev/null +++ b/resources/js/canvas/deploy/Field.svelte @@ -0,0 +1,105 @@ + + +{#if f.kind === 'note'} +

    {f.text}

    +{:else} +
    + + + {#if f.kind === 'segmented'} +
    + {#each f.options as [option, text]} + + {/each} +
    + {:else if f.kind === 'select'} + + {#if error}{error}{/if} + {:else if f.kind === 'textarea'} + + {:else} + set(e.currentTarget.value)} + onpointerdown={stop} + onkeydown={stop} + /> + {/if} +
    +{/if} diff --git a/resources/js/canvas/deploy/Icon.svelte b/resources/js/canvas/deploy/Icon.svelte new file mode 100644 index 0000000000..f81085b6da --- /dev/null +++ b/resources/js/canvas/deploy/Icon.svelte @@ -0,0 +1,25 @@ + + +{#if glyph} + {@html glyph} +{:else} + +{/if} diff --git a/resources/js/canvas/deploy/Node.svelte b/resources/js/canvas/deploy/Node.svelte new file mode 100644 index 0000000000..b6f36e6965 --- /dev/null +++ b/resources/js/canvas/deploy/Node.svelte @@ -0,0 +1,104 @@ + + +
    + +
    dragNode(n, e)}> + + {#if logoSrc && !broken[logoSrc]} + (broken[logoSrc] = true)} /> + {#if logo.dark}{/if} + {:else} + + {/if} + +
    {t.title}{t.sub}
    + {#if removable} + + {/if} +
    +
    + {#each fields as f (f)} + + {/each} + {#if n.type === 'deploy'} + {#if !plan.lines.length} +

    Every resource you connect to a Server shows here.

    + {/if} + {#each plan.lines as { source } (source.id)} + +
    + {/each} + +
    + + +
    + {/if} + {#if t.stage === 'config' && !sourceAbove(n.id, cv.flow)} +

    Connect it after a source step.

    + {/if} +
    + {#if n.type !== 'start'}{/if} + {#if st?.message}
    {st.message}
    {/if} + {#if n.type !== 'deploy'} + + startConnect(n, e)}> + {/if} + {#if blank} +
    Drag and drop to add a step
    + {/if} +
    diff --git a/resources/js/canvas/deploy/PaletteItem.svelte b/resources/js/canvas/deploy/PaletteItem.svelte new file mode 100644 index 0000000000..f7928cfb04 --- /dev/null +++ b/resources/js/canvas/deploy/PaletteItem.svelte @@ -0,0 +1,21 @@ + + + diff --git a/resources/js/canvas/deploy/RunPanel.svelte b/resources/js/canvas/deploy/RunPanel.svelte new file mode 100644 index 0000000000..77d5fc249e --- /dev/null +++ b/resources/js/canvas/deploy/RunPanel.svelte @@ -0,0 +1,48 @@ + + + diff --git a/resources/js/canvas/deploy/index.js b/resources/js/canvas/deploy/index.js new file mode 100644 index 0000000000..876d4487fd --- /dev/null +++ b/resources/js/canvas/deploy/index.js @@ -0,0 +1,18 @@ +// Deploy canvas (Svelte): mounted on the /canvas page into [data-deploy-canvas], +// on the first load and after Livewire SPA navigation. + +import { mount, unmount } from 'svelte'; +import { mountOnPage } from '../../canvas-core.js'; +import DeployCanvas from './DeployCanvas.svelte'; +import { init } from './state.svelte.js'; +import { injectStyles } from './styles.js'; + +export function initializeDeployCanvas() { + mountOnPage('[data-deploy-canvas]', '.cv-root', container => { + injectStyles(); + init(container); + container.replaceChildren(); + const app = mount(DeployCanvas, { target: container }); + return { root: container.querySelector('.cv-root'), unmount: () => unmount(app) }; + }); +} diff --git a/resources/js/canvas/deploy/state.svelte.js b/resources/js/canvas/deploy/state.svelte.js new file mode 100644 index 0000000000..666955915a --- /dev/null +++ b/resources/js/canvas/deploy/state.svelte.js @@ -0,0 +1,661 @@ +// Deploy canvas runtime: the state of the open canvas, what the steps do to +// it (add, connect, run…), and the Svelte actions around the imperative +// helpers of canvas-core.js (pan/zoom, top bar placement, card sizes). + +import { flushSync, tick } from 'svelte'; +import { api, freeSpot, MIN_ZOOM, panZoom } from '../../canvas-core.js'; +import { + canFollow, defaults, domainList, fresh, LOG_POLL_MS, lists, load, multiOut, nextOf, nodeById, outsOf, planOf, + resourceHref, save, sleep, targetFrom, TYPES, uid, uniqueLabels, withDeploy, withoutSecrets, withTarget, +} from '../../deploy-canvas.js'; + +export const PORT_Y = 26; // header centre (3.25rem / 2) +const CARD_W = 296; + +const emptyRun = () => ({ open: false, title: 'Deploy', sub: '', steps: [], panes: [], active: null, defaultHref: '', openHref: '', showOpen: false }); + +export const cv = $state({ + canAdd: false, // may create projects and environments (from the page) + target: null, // the environment that opened the canvas + flow: fresh(), + view: { x: 40, y: 40, z: 1 }, + runState: {}, // node id -> { state: running|done|failed, message } + invalid: {}, // node id -> true: a required field is empty + // The last successful run's addresses, shown as a pill after Deploy. + result: null, // { after: node id, urls: [], href } + running: false, // false, 'save' or 'deploy' + sizes: {}, // node id -> { w, h } of its card + listsVersion: 0, // bumped when an option list loads + menu: null, // the "Add the next step" menu: { prev, valid, p, left, top } + pendingLine: null, // the dragged line, kept while its menu is open + temp: null, // the line being dragged from a port + armed: null, // node id of the port being dragged + dropTarget: null, // node id the dragged line would connect to + toast: null, // { text, bad } + dropdown: null, // { trigger, items, current, onPick, add } + run: emptyRun(), // the run panel +}); + +// DOM of the open canvas (set by the actions below); mount counts the +// canvases, so a run of an earlier one stops writing here. +export const els = { root: null, stage: null, viewport: null, pz: null, nodeEls: new Map(), mount: 0, fitOnOpen: false }; + +lists.onChange = () => { cv.listsVersion++; }; + +export function init(container) { + els.mount++; + els.nodeEls.clear(); + cv.canAdd = container.dataset.canAdd === '1'; + if (load('deployed', false)) { + save('flow', null); + save('result', null); + save('deployed', false); + } + cv.target = targetFrom(location.search); + cv.flow = withTarget(withDeploy(load('flow', null) || fresh()), cv.target); + save('flow', withoutSecrets(cv.flow)); // drops secrets that older versions stored + // Small screens: the default view is made for desktop, so the first visit fits the steps instead. + els.fitOnOpen = !load('view', null) && !matchMedia('(min-width: 768px)').matches; + cv.view = load('view', { x: 40, y: 40, z: 1 }); + cv.result = load('result', null); + Object.assign(cv, { runState: {}, invalid: {}, running: false, sizes: {}, menu: null, pendingLine: null, temp: null, armed: null, dropTarget: null, toast: null, dropdown: null, run: emptyRun() }); +} + +/** The canvas left the page: its runs stop. */ +export function leave() { + els.mount++; +} + +// ── Actions ──────────────────────────────────────────────────────────── + +/** use:pan on the viewport: wheel and pinch zoom, drag to pan. zoomLabel() is the zoom button. */ +export function pan(viewport, zoomLabel) { + const world = viewport.querySelector('.cv-world'); + const edges = viewport.querySelector('.cv-edges'); + els.viewport = viewport; + els.stage = viewport.parentElement; + els.root = viewport.closest('.cv-root'); + els.pz = panZoom({ + root: els.root, + viewport, + world, + // The button can sit in the app top bar (see dock), so it is looked up on each write. + zoomLabel: { set textContent(text) { const label = zoomLabel(); if (label) label.textContent = text; } }, + getView: () => cv.view, + onChange: closeDropdown, + canPan: e => [viewport, world, edges].includes(e.target), + onPanStart: closeMenu, + }); +} + +const DESKTOP = '(min-width: 1024px)'; +/** + * use:dock on the header. Desktop: it docks into the app top bar, or with the + * floating nav it is a separate panel next to the top bar (as on the + * environment canvas) when it fits there. Small screens: a box above the canvas. + */ +export function dock(top) { + const root = top.closest('.cv-root'); + const stage = root.querySelector('.cv-stage'); + function place() { + const desktop = matchMedia(DESKTOP).matches; + const slot = desktop && document.getElementById('page-toolbar-slot'); + const topbar = document.querySelector('.app-topbar'); + top.removeAttribute('style'); + if (desktop && topbar && document.documentElement.dataset.navStyle === 'floating') { + top.className = 'fixed z-40 flex items-center gap-3 rounded-xl border border-neutral-200 bg-white px-2 py-1.5 dark:border-white/[0.08] dark:bg-panel'; + root.append(top); + const gap = 8; + const head = topbar.getBoundingClientRect(); + const width = top.offsetWidth; + if (head.right + gap + width <= innerWidth - gap) { + // Centered over the canvas, but never over the top bar. + const box = stage.getBoundingClientRect(); + const left = Math.max(box.left + (box.width - width) / 2, head.right + gap); + Object.assign(top.style, { top: `${head.top}px`, left: `${left}px`, minHeight: `${head.height}px` }); + root.classList.add('cv-docked'); + return; + } + top.removeAttribute('style'); + } + top.className = slot ? 'flex items-center gap-3 pl-3' : 'cv-island cv-top'; + root.classList.toggle('cv-docked', !!slot); + if (slot) slot.replaceChildren(top); + else root.prepend(top); + } + place(); + const replace = () => root.isConnected && place(); + // The floating header is centered over the canvas next to the app top bar, so follow their sizes. + const sizes = new ResizeObserver(replace); + for (const target of [stage, document.querySelector('.app-topbar')].filter(Boolean)) sizes.observe(target); + const media = matchMedia(DESKTOP); + media.addEventListener('change', replace); + addEventListener('resize', replace); + const navStyle = new MutationObserver(replace); + navStyle.observe(document.documentElement, { attributes: true, attributeFilter: ['data-nav-style'] }); + return { + destroy() { + sizes.disconnect(); + media.removeEventListener('change', replace); + removeEventListener('resize', replace); + navStyle.disconnect(); + top.remove(); // it may sit in the app top bar + }, + }; +} + +/** use:track on a card: its element (for measuring) and its size (for the lines). */ +export function track(node, id) { + els.nodeEls.set(id, node); + const measure = () => { cv.sizes[id] = { w: node.offsetWidth, h: node.offsetHeight }; }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(node); + return { + destroy() { + observer.disconnect(); + if (els.nodeEls.get(id) === node) els.nodeEls.delete(id); + delete cv.sizes[id]; + }, + }; +} + +// ── Canvas: geometry, fit ────────────────────────────────────────────── +// Port positions in world coordinates. +export const outPoint = n => ({ x: n.x + (cv.sizes[n.id]?.w || CARD_W), y: n.y + PORT_Y }); +export const inPoint = n => ({ x: n.x, y: n.y + PORT_Y }); +export const curve = (a, b) => { + const dx = Math.max(40, Math.abs(b.x - a.x) / 2); + return `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`; +}; +const toWorld = (cx, cy) => els.pz.toWorld(cx, cy); +const width = n => els.nodeEls.get(n.id)?.offsetWidth || CARD_W; +const height = n => els.nodeEls.get(n.id)?.offsetHeight || 160; +// A card's box in world units (its rendered size, or a usual card size). +const boxOf = n => ({ x: n.x, y: n.y, w: width(n), h: height(n) }); + +export function fit() { + if (!els.nodeEls.size) return; + let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; + for (const n of cv.flow.nodes) { + const node = els.nodeEls.get(n.id); + minX = Math.min(minX, n.x); + minY = Math.min(minY, n.y); + maxX = Math.max(maxX, n.x + (node?.offsetWidth || 280)); + maxY = Math.max(maxY, n.y + (node?.offsetHeight || 120)); + } + const pill = els.root.querySelector('.cv-result'); + if (pill) { + maxX = Math.max(maxX, pill.offsetLeft + pill.offsetWidth); + maxY = Math.max(maxY, pill.offsetTop + pill.offsetHeight); + } + const r = els.viewport.getBoundingClientRect(); + const pad = 60; + const view = cv.view; + view.z = Math.min(1.2, Math.max(MIN_ZOOM, Math.min((r.width - pad * 2) / (maxX - minX), (r.height - pad * 2) / (maxY - minY)))); + view.x = (r.width - (maxX - minX) * view.z) / 2 - minX * view.z; + view.y = (r.height - (maxY - minY) * view.z) / 2 - minY * view.z; + els.pz.apply(); +} + +// ── Toast, menus, dropdown ───────────────────────────────────────────── +let toastTimer = 0; +export function toast(text, bad = false) { + cv.toast = { text, bad }; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { cv.toast = null; }, bad ? 4200 : 2600); +} + +export function closeMenu() { + cv.menu = null; + cv.pendingLine = null; +} +// Close an older menu but keep the line being dropped. +function openNextMenu(prev, cx, cy, p) { + cv.menu = null; + const valid = Object.keys(TYPES).filter(type => canFollow(prev, type, null, cv.flow)); + if (!valid.length) return toast('Nothing can follow this step.', true); + const r = els.stage.getBoundingClientRect(); + cv.menu = { prev: prev.id, valid, p, left: Math.min(cx - r.left, r.width - 220), top: Math.min(cy - r.top, r.height - 40 * valid.length - 40) }; +} +export function pickNext(type) { + const { prev, p } = cv.menu; + closeMenu(); + const n = addNode(type, p.x + 8, p.y - PORT_Y, true); + connect(prev, n.id); +} + +// Our dropdown panel (fixed in the canvas root, so zoom never shrinks it). +export function closeDropdown() { + cv.dropdown = null; +} +export function openDropdown(trigger, items, current, onPick, add = null) { + if (cv.dropdown?.trigger === trigger) return closeDropdown(); + cv.dropdown = { trigger, items, current, onPick, add }; +} + +// ── Flow edits ───────────────────────────────────────────────────────── +// A structural change redraws the cards, which clears their invalid marks. +const redraw = () => { cv.invalid = {}; }; + +export function connect(from, to) { + const a = nodeById(from, cv.flow); + cv.flow.edges = cv.flow.edges.filter(e => !(e.from === from && e.to === to) && (multiOut(a) || e.from !== from) && e.to !== to); + cv.flow.edges.push({ from, to }); + redraw(); +} +export function removeEdge(edge) { + if (cv.running) return; + cv.flow.edges = cv.flow.edges.filter(e => e !== edge); + redraw(); + toast('Connection removed.'); +} +export function addNode(type, x, y, quiet) { + cv.flow.nodes.push({ id: uid(), type, x: Math.round(x), y: Math.round(y), data: defaults(type) }); + const n = cv.flow.nodes.at(-1); + redraw(); + flushSync(); // render the card, to measure it + settle(n); + if (!quiet) toast(`${TYPES[type].title} added. Connect it from the dot of the step before.`); + return n; +} +export function removeNode(id) { + if (cv.running) return; + cv.flow.nodes = cv.flow.nodes.filter(n => n.id !== id); + cv.flow.edges = cv.flow.edges.filter(e => e.from !== id && e.to !== id); + delete cv.runState[id]; + redraw(); +} +export function resetCanvas() { + if (cv.running) return; + cv.flow = withTarget(fresh(), cv.target); + cv.runState = {}; + cv.result = null; + save('result', null); + save('deployed', false); + redraw(); +} +export function hideResult() { + cv.result = null; + save('result', null); +} +// A field change. Fields loaded from the one changed (`rerender`) start empty again. +export function setField(n, f, value) { + const fields = TYPES[n.type].fields; + n.data[f.key] = value; + f.after?.(n.data); + if (f.rerender) { + for (const other of fields) if (other.load && other !== f && other.key !== f.key && fields.indexOf(other) > fields.indexOf(f)) delete n.data[other.key]; + } + delete cv.invalid[n.id]; +} + +// Move a card to the nearest spot where it does not cover another card. +function settle(n) { + const spot = freeSpot(boxOf(n), cv.flow.nodes.filter(m => m !== n).map(boxOf)); + if (!spot || (spot.x === n.x && spot.y === n.y)) return; + n.x = Math.round(spot.x); + n.y = Math.round(spot.y); +} +// Click on a step in the list: add it after the step it most likely follows. +function appendToChain(type) { + const flow = cv.flow; + const plan = planOf(flow); + // The newest step in the tree this one can follow, with room for it. + const inTree = [plan.start, ...plan.servers, ...plan.lines.flatMap(l => [l.source, ...l.settings])]; + const parent = inTree.reverse().find(n => canFollow(n, type, null, flow) && (multiOut(n) || !nextOf(n.id, flow))); + if (!parent) return addNode(type, ...centre()); + const siblings = multiOut(parent) ? outsOf(parent.id, flow).map(id => nodeById(id, flow)).filter(Boolean) : []; + const y = siblings.length ? Math.max(...siblings.map(n => n.y + height(n))) + 40 : parent.y; + const n = addNode(type, parent.x + width(parent) + 80, y, true); + connect(parent.id, n.id); +} +function centre() { + const r = els.viewport.getBoundingClientRect(); + const p = toWorld(r.left + r.width / 2, r.top + r.height / 2); + return [p.x - 140, p.y - 60]; +} + +// ── Pointer drags ────────────────────────────────────────────────────── + +/** A palette step: drag it to the canvas, or click to append it to the chain. */ +export function dragFromPalette(type, event) { + if (event.button !== 0) return; + const item = event.currentTarget; + const startX = event.clientX; + const startY = event.clientY; + let ghost = null; + const move = e => { + if (!ghost && Math.hypot(e.clientX - startX, e.clientY - startY) < 5) return; + if (!ghost) { + ghost = item.cloneNode(true); + Object.assign(ghost.style, { position: 'fixed', zIndex: 2147483001, width: `${item.offsetWidth}px`, pointerEvents: 'none', opacity: 0.9, background: 'var(--cv-ghost)', borderRadius: '8px', boxShadow: '0 8px 24px -8px rgb(0 0 0 / 0.35)' }); + ghost.style.setProperty('--cv-ghost', 'light-dark(#fff, #222)'); + els.root.append(ghost); + } + ghost.style.left = `${e.clientX - 20}px`; + ghost.style.top = `${e.clientY - 18}px`; + }; + const stop = () => { + removeEventListener('pointermove', move); + removeEventListener('pointerup', up); + removeEventListener('pointercancel', cancel); + }; + // Touch: a sideways swipe scrolls the step strip (small screens) and cancels the pointer. + const cancel = () => { + stop(); + ghost?.remove(); + }; + const up = e => { + stop(); + if (!ghost) return appendToChain(type); + ghost.remove(); + const r = els.viewport.getBoundingClientRect(); + if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) return; + const p = toWorld(e.clientX, e.clientY); + addNode(type, p.x - 30, p.y - 16); + }; + addEventListener('pointermove', move); + addEventListener('pointerup', up); + addEventListener('pointercancel', cancel); +} + +/** + * Drag a card by its header. Pointer capture keeps the drag even when the + * pointer outruns the card. + */ +export function dragNode(n, event) { + if (event.button !== 0 || event.target.closest('button, input, select, textarea')) return; + const handle = event.currentTarget; + event.stopPropagation(); + event.preventDefault(); + closeMenu(); + try { handle.setPointerCapture(event.pointerId); } catch { /* not capturable */ } + const ox = event.clientX / cv.view.z - n.x; + const oy = event.clientY / cv.view.z - n.y; + // The other cards stay put during the drag, so measure them once. + const others = cv.flow.nodes.filter(m => m !== n).map(boxOf); + const move = e => { + if (e.pointerId !== event.pointerId) return; + const wanted = { x: e.clientX / cv.view.z - ox, y: e.clientY / cv.view.z - oy, w: width(n), h: height(n) }; + // Cards cannot cover each other: stop at the edge of a card in the way. + const spot = freeSpot(wanted, others); + if (!spot) return; + n.x = Math.round(spot.x); + n.y = Math.round(spot.y); + }; + const up = e => { + if (e.pointerId !== event.pointerId) return; + removeEventListener('pointermove', move); + removeEventListener('pointerup', up); + removeEventListener('pointercancel', up); + }; + addEventListener('pointermove', move); + addEventListener('pointerup', up); + addEventListener('pointercancel', up); +} + +// Drag from an output port: drop on a node to connect, on empty space to pick. +export function startConnect(n, event) { + event.stopPropagation(); + event.preventDefault(); + if (cv.running) return; + cv.armed = n.id; + const a = outPoint(n); + let target = null; + const move = e => { + cv.temp = { a, b: toWorld(e.clientX, e.clientY) }; + const hit = document.elementFromPoint(e.clientX, e.clientY)?.closest?.('.cv-node'); + const id = hit?.dataset.id; + const candidate = id && id !== n.id ? nodeById(id, cv.flow) : null; + target = candidate && canFollow(n, candidate.type, candidate, cv.flow) ? candidate : null; + cv.dropTarget = target?.id ?? null; + }; + const up = e => { + removeEventListener('pointermove', move); + removeEventListener('pointerup', up); + cv.armed = null; + cv.temp = null; + cv.dropTarget = null; + if (target) return connect(n.id, target.id); + const over = document.elementFromPoint(e.clientX, e.clientY); + if (over?.closest?.('.cv-node')) return toast('That step cannot follow this one.', true); + const p = toWorld(e.clientX, e.clientY); + if (Math.hypot(p.x - a.x, p.y - a.y) < 12) return; + cv.pendingLine = { a, b: p }; + openNextMenu(n, e.clientX, e.clientY, p); + }; + addEventListener('pointermove', move); + addEventListener('pointerup', up); +} + +// ── Run ──────────────────────────────────────────────────────────────── +// Log panes: "all" holds the steps; each deployment gets its own tab and pane. +const paneOf = key => cv.run.panes.find(pane => pane.key === key); +function openPane(key, label) { + if (!paneOf(key)) cv.run.panes.push({ key, label, href: '', state: '', status: '', lines: [] }); + return paneOf(key); +} +export function showPane(key) { + const r = cv.run; + r.active = key; + const href = paneOf(key)?.href || r.defaultHref; + if (href) r.openHref = href; +} +function setPaneState(key, state, status) { + const pane = paneOf(key); + if (!pane) return; + pane.state = state; + pane.status = status; +} +function logLine(text, cls = '', key = 'all') { + const pane = paneOf(key) || paneOf('all'); + const box = els.root?.querySelector(`.cv-logs[data-key="${pane.key}"]`); + const atEnd = !box || box.scrollTop + box.clientHeight >= box.scrollHeight - 30; + pane.lines.push({ text, cls }); + if (box && atEnd) tick().then(() => { box.scrollTop = box.scrollHeight; }); +} +function setStep(n, state, message) { + cv.runState[n.id] = { state, message }; + delete cv.invalid[n.id]; + const row = cv.run.steps.find(s => s.id === n.id); + if (row) { + row.state = state; + row.message = message || ''; + } +} + +function validate(plan) { + const problems = []; + if (!plan.servers.length) problems.push('Connect a Server right after Start.'); + else if (!plan.lines.length) problems.push('Connect a source step (Git repository, Docker image, Dockerfile, Service or Database) to a Server.'); + for (const n of [plan.start, ...new Set(plan.lines.map(l => l.server)), ...plan.lines.flatMap(l => [l.source, ...l.settings])]) { + const missing = TYPES[n.type].fields.filter(f => f.required && (!f.show || f.show(n.data)) && !String(n.data[f.key] ?? '').trim()).map(f => f.label); + if (missing.length) { + problems.push(`${TYPES[n.type].title}: fill in ${missing.join(', ')}.`); + cv.invalid[n.id] = true; + } + } + return problems; +} + +export async function runFlow(action = 'deploy') { + if (cv.running) return; + closeMenu(); + const plan = planOf(cv.flow); + const problems = validate(plan); + if (problems.length) { + toast(problems[0], true); + return; + } + cv.running = action; + cv.runState = {}; + cv.result = null; + save('result', null); + save('deployed', false); + const servers = [...new Set(plan.lines.map(l => l.server))]; + const list = [plan.start, ...servers, ...plan.lines.flatMap(l => [l.source, ...l.settings]), plan.deploy]; + const names = uniqueLabels(plan.lines.map(l => l.source)); + const labelOf = n => names.get(n.id) || TYPES[n.type].title; + const r = cv.run; + r.open = true; + r.title = action === 'save' ? 'Saving' : 'Deploying'; + r.sub = `${plan.lines.length} resource${plan.lines.length === 1 ? '' : 's'}`; + r.panes = []; + r.active = null; + r.defaultHref = ''; + openPane('all', 'Steps'); + showPane('all'); + r.showOpen = false; + r.steps = list.map(n => ({ id: n.id, label: labelOf(n), state: '', message: 'Waiting' })); + redraw(); + // The page can be left (and the canvas mounted again) during a run. + const owner = els.mount; + const live = () => els.mount === owner; + // Run one step; false when it failed. + const step = async (n, work) => { + setStep(n, 'running', 'Running…'); + try { + const message = await work(); + if (!live()) return false; + setStep(n, 'done', message); + logLine(`✓ ${labelOf(n)}: ${message}`, 'dim'); + return true; + } catch (error) { + if (!live()) return false; + setStep(n, 'failed', error.message); + logLine(`✗ ${labelOf(n)}: ${error.message}`, 'err'); + return false; + } + }; + const base = {}; + let ok = await step(plan.start, () => TYPES.start.run(base, plan.start.data)); + for (const server of servers) { + if (!ok) break; + ok = await step(server, async () => 'Server picked.'); + } + const shipped = []; // { line, ctx, label } per created resource + for (const line of plan.lines) { + if (!ok) break; + const ctx = { ...base }; + await TYPES.server.run(ctx, line.server.data); + const domainNode = line.settings.find(n => n.type === 'domain'); + if (domainNode) ctx.domains = domainList(domainNode.data.domains); + for (const n of [line.source, ...line.settings]) { + ok = await step(n, () => TYPES[n.type].run(ctx, n.data)); + if (!ok) break; + } + if (ctx.resource) shipped.push({ line, ctx, label: names.get(line.source.id) }); + } + if (!live()) return; + const href = () => shipped.length === 1 ? resourceHref(shipped[0].ctx) : `/project/${base.project}/environment/${base.environment}`; + if (shipped.length) { + r.showOpen = true; + r.openHref = r.defaultHref = href(); + } + if (ok) { + ok = await step(plan.deploy, async () => action === 'save' + ? `Saved ${shipped.length} resource${shipped.length === 1 ? '' : 's'}. Not deployed.` + : deployAll(shipped, live)); + } + if (!live()) return; + if (shipped.length) r.defaultHref = href(); + showPane(r.active); + if (ok) { + const urls = []; + for (const { ctx } of action === 'save' ? [] : shipped) { + let found = String(ctx.domains || ctx.createdDomains || '').split(',').map(v => v.trim()).filter(Boolean); + if (!found.length && ctx.resource.kind === 'application') { + try { + const app = await api('GET', `/applications/${ctx.resource.uuid}`); + found = String(app?.fqdn || '').split(',').map(v => v.trim()).filter(Boolean); + } catch { /* keep none */ } + } + urls.push(...found); + } + cv.result = { after: plan.deploy.id, urls, href: href() }; + save('result', cv.result); + // The next visit starts with an empty canvas. + if (action === 'deploy') save('deployed', true); + } + r.title = ok ? 'Done' : 'Stopped'; + r.sub = ok ? (action === 'save' ? 'Saved in Coolify. Deploy them from there or from here.' : 'Every step finished.') : 'A step failed. Fix it and try again.'; + cv.running = false; +} + +// Deploy every resource at once and follow them. +async function deployAll(shipped, live) { + const many = shipped.length > 1; + // A tab per resource, in order; the first one opens. + for (const { line, ctx, label } of shipped) openPane(line.source.id, label).href = resourceHref(ctx); + if (shipped.length) showPane(shipped[0].line.source.id); + const results = await Promise.allSettled(shipped.map(async ({ line, ctx, label }) => { + const key = line.source.id; + setPaneState(key, 'running', 'Starting'); + try { + const res = await api('POST', '/deploy', { uuid: ctx.resource.uuid }); + if (!live()) return 'Left the canvas.'; + const item = res?.deployments?.[0]; + let message = item?.message || 'Started.'; + if (item?.deployment_uuid) { + ctx.deployment = item.deployment_uuid; + paneOf(key).href = resourceHref(ctx); + if (cv.run.active === key) showPane(key); + message = await followDeployment(label, item.deployment_uuid, key, live); + if (!live()) return message; + } else { + logLine(message, '', key); + } + setPaneState(key, 'done', message); + logLine(`✓ ${label}: ${message}`, 'dim'); + return `${label}: ${message}`; + } catch (error) { + if (live()) { + setPaneState(key, 'failed', error.message.replace(`${label}: `, '')); + logLine(error.message, 'err', key); + } + throw error; + } + })); + const failed = results.filter(res => res.status === 'rejected').map(res => res.reason.message); + if (failed.length) throw new Error(failed.join(' · ')); + return many ? `${results.length} resources deployed.` : results[0].value; +} + +// Poll the deployment (Coolify's own log view also polls every 2 s). +// Logs go to the resource's own pane (key). +async function followDeployment(label, deploymentUuid, key, live) { + logLine(`Deployment ${deploymentUuid}`, 'dim', key); + let shown = 0; + let warned = false; + for (;;) { + if (!live()) return 'Left the canvas.'; + let dep; + try { + dep = await api('GET', `/deployments/${deploymentUuid}`); + } catch (error) { + if (!live()) return 'Left the canvas.'; + logLine(`(could not read the deployment: ${error.message})`, 'err', key); + await sleep(LOG_POLL_MS * 2); + continue; + } + if (!live()) return 'Left the canvas.'; + let entries = []; + if (typeof dep.logs === 'string') { + try { entries = JSON.parse(dep.logs) || []; } catch { entries = []; } + } else if (dep.logs === undefined && !warned) { + warned = true; + logLine('Logs are hidden: only team admins can see deployment logs.', 'err', key); + } + const visible = entries.filter(entry => !entry.hidden); + for (const entry of visible.slice(shown)) { + for (const line of String(entry.output ?? '').split('\n')) if (line.trim()) logLine(line, entry.type === 'stderr' ? 'err' : '', key); + } + shown = visible.length; + setPaneState(key, 'running', String(dep.status || '').replace(/_/g, ' ')); + if (dep.status === 'finished') return 'Deployed.'; + if (dep.status === 'failed') throw new Error(`${label}: deployment failed. See the logs.`); + if (dep.status === 'cancelled-by-user') throw new Error(`${label}: deployment cancelled.`); + await sleep(LOG_POLL_MS); + } +} diff --git a/resources/js/canvas/deploy/styles.js b/resources/js/canvas/deploy/styles.js new file mode 100644 index 0000000000..e04d9951cd --- /dev/null +++ b/resources/js/canvas/deploy/styles.js @@ -0,0 +1,319 @@ +// The deploy canvas look: islands on a backdrop. The colors are CSS variables +// on .cv-root, so a custom color scheme can replace them. Injected once as a +// diff --git a/resources/js/canvas/environment/Icon.svelte b/resources/js/canvas/environment/Icon.svelte new file mode 100644 index 0000000000..8a715697fa --- /dev/null +++ b/resources/js/canvas/environment/Icon.svelte @@ -0,0 +1,10 @@ + + + diff --git a/resources/js/canvas/environment/NodeIcon.svelte b/resources/js/canvas/environment/NodeIcon.svelte new file mode 100644 index 0000000000..920d30b42a --- /dev/null +++ b/resources/js/canvas/environment/NodeIcon.svelte @@ -0,0 +1,37 @@ + + +{#if faviconShown} + +{:else} + {#if !logo?.src || broken} + + {:else} + + + {#if logo.dark} + + {/if} + + {/if} + {#if logo?.favicon && !failed[logo.favicon]} + (loadedFavicon = logo.favicon)} onerror={() => (failed[logo.favicon] = true)} /> + {/if} +{/if} diff --git a/resources/js/canvas/environment/Panel.svelte b/resources/js/canvas/environment/Panel.svelte new file mode 100644 index 0000000000..8086ac4bcd --- /dev/null +++ b/resources/js/canvas/environment/Panel.svelte @@ -0,0 +1,324 @@ + + +{#snippet section(title, content)} +
    +

    {title}

    + {@render content()} +
    +{/snippet} + +{#snippet note(content)} +

    {content}

    +{/snippet} + +{#snippet shell(title, iconSource, body, footer)} + +{/snippet} + +{#snippet meta(n)} +
    + {TYPE_LABELS[n.type] || n.type} + {statusLabel(n.status)} +
    +{/snippet} + +{#snippet openButton(n, cls, label)} + {label} +{/snippet} + +{#snippet removeButton(l)} + {#if canEdit} + + {/if} +{/snippet} + +{#if connectView && node && isSource(node)} + {#snippet body()} +
    + {#if node.reference}{node.reference}{/if} + {#if node.missing}Deleted{/if} +
    + {#if node.missing} + {@render note(`This shared variable was deleted. The resources below still reference it and get the text ${node.reference} instead of a value. Add the variable again, or remove the reference.`)} + {:else if node.subtitle} + {#snippet description()}

    {node.subtitle}

    {/snippet} + {@render section('Description', description)} + {/if} + {#snippet used()} +
      + {#if !usedBy.length}
    • {text.noTargets}
    • {/if} + {#each usedBy as l (l.id)} + {@const resource = nodeById(l.target)} +
    • + + {@render removeButton(l)} +
    • + {/each} +
    + {/snippet} + {@render section(`${text.usedBy} (${usedBy.length})`, used)} + {#if canEdit && !node.missing} + {@const linked = new Set(usedBy.map(l => l.target))} + !isSource(n) && !linked.has(n.id)), + resource => ({ title: `Add ${node.name} to ${resource.name}`, sub: resource.context || '' }), + resource => oncreate(node.id, resource.id))} /> + {/if} + {#if !node.missing}{@render note(canEdit ? text.note(node) : text.viewOnly)}{/if} + {/snippet} + {#snippet footer()}{@render openButton(node, '', `Open ${TYPE_LABELS[node.type].toLowerCase()}`)}{/snippet} + {@render shell(node.name, node, body, node.link ? footer : null)} +{:else if connectView && node} + {#snippet body()} + {@render meta(node)} + {#if node.context} + {#snippet context()}{node.context}{/snippet} + {@render section('Project / environment', context)} + {/if} + {#snippet list()} +
      + {#if !sources.length}
    • {text.noSources}
    • {/if} + {#each sources as l (l.id)} + {@const source = nodeById(l.source)} +
    • + + {@render removeButton(l)} +
    • + {/each} +
    + {/snippet} + {@render section(`${text.sources} (${sources.length})`, list)} + {#if canEdit} + {@const linked = new Set(sources.map(l => l.source))} + isSource(n) && !n.missing && !linked.has(n.id)), + source => ({ title: `Add ${source.name} to ${node.name}`, sub: source.type === 'variable' ? source.subtitle || '' : '' }), + source => oncreate(source.id, node.id))} /> + {/if} + {/snippet} + {#snippet footer()}{@render openButton(node, 'button-highlighted', 'Open')}{/snippet} + {@render shell(node.name, node, body, node.link ? footer : null)} +{:else if connectView && link} + {@const variable = nodeById(link.source)} + {@const resource = nodeById(link.target)} + {#snippet body()} + {#snippet ends()} +
    + {#each [[text.source, variable], ['Resource', resource]] as [endLabel, end] (endLabel)} + + {/each} +
    + {/snippet} + {@render section('Ends', ends)} + {#if link.keys?.length} + {#snippet keys()} +
    + {#each link.keys as key (key)}{key}{/each} +
    + {/snippet} + {@render section(`Variables on ${resource?.name || 'the resource'}`, keys)} + {/if} + {#if link.missing}{@render note(`The shared variable was deleted. ${resource?.name} gets the text ${variable?.reference} instead of a value.`)}{/if} + {#if canEdit && !link.removable}{@render note(removeHint(link))}{/if} + {/snippet} + {#snippet footer()} + + {/snippet} + {@render shell(`${variable?.name || text.source} → ${resource?.name || 'Resource'}`, 'link', body, canEdit ? footer : null)} +{:else if node} + {#snippet body()} + {@render meta(node)} + {#if node.subtitle} + {#snippet details()}

    {node.subtitle}

    {/snippet} + {@render section('Details', details)} + {/if} + {#if node.urls?.length} + {#snippet urls()} + + {/snippet} + {@render section(node.urls.length === 1 ? 'URL' : 'URLs', urls)} + {/if} + {#snippet serverList()}

    {servers.length ? servers.join(', ') : NO_SERVER}

    {/snippet} + {@render section(servers.length > 1 ? 'Servers' : 'Server', serverList)} + {#if node.children?.length} + {#snippet containers()} +
      + {#each node.children as child, i (i)} +
    • + + {child.name} + {TYPE_LABELS[child.type] || child.type || ''} +
    • + {/each} +
    + {/snippet} + {@render section('Containers', containers)} + {/if} + {#if node.volumes?.length} + {#snippet volumes()} +
    + {#each node.volumes as v, i (i)}{v}{/each} +
    + {/snippet} + {@render section('Volumes', volumes)} + {/if} + {#snippet linkList()} +
      + {#if !nodeLinks.length}
    • {canEdit ? 'No links. Drag the dot on the right edge of a card to another card.' : 'No links.'}
    • {/if} + {#each nodeLinks as l (l.id)} +
    • + +
    • + {/each} +
    + {/snippet} + {@render section('Links', linkList)} + {/snippet} + {#snippet footer()}{@render openButton(node, 'button-highlighted', 'Open')}{/snippet} + {@render shell(node.name, node, body, node.link ? footer : null)} +{:else if link} + {#snippet body()} + {#snippet ends()} +
    + {#each [['From', link.source], ['To', link.target]] as [endLabel, id] (endLabel)} + {@const end = nodeById(id)} + + {/each} +
    + {/snippet} + {@render section('Resources', ends)} + {#snippet labelField()} + {#if canEdit} + { + if (e.key === 'Enter') { + e.preventDefault(); + labelInput.blur(); + } else if (e.key === 'Escape') { + e.stopPropagation(); + label = saved; + labelInput.blur(); + } + }} + onblur={saveLabel} + /> + {:else} +

    {link.label || 'No label'}

    + {/if} + {/snippet} + {@render section('Label', labelField)} + {@render note('Links are for the diagram only. They do not change your resources.')} + {/snippet} + {#snippet footer()} + + {/snippet} + {@render shell(link.label || 'Link', 'link', body, canEdit ? footer : null)} +{/if} diff --git a/resources/js/canvas/environment/StatusDot.svelte b/resources/js/canvas/environment/StatusDot.svelte new file mode 100644 index 0000000000..26bed75873 --- /dev/null +++ b/resources/js/canvas/environment/StatusDot.svelte @@ -0,0 +1,13 @@ + + + + {#if tone === 'busy'} + + {/if} + + diff --git a/resources/js/canvas/environment/Toolbar.svelte b/resources/js/canvas/environment/Toolbar.svelte new file mode 100644 index 0000000000..f286c791a0 --- /dev/null +++ b/resources/js/canvas/environment/Toolbar.svelte @@ -0,0 +1,51 @@ + + +
    +
    + {#if title}{title}{/if} + + Beta + + View only +
    + +
    + + + + +
    + + + +
    + +
    +
    diff --git a/resources/js/canvas/environment/Zone.svelte b/resources/js/canvas/environment/Zone.svelte new file mode 100644 index 0000000000..2bbc915609 --- /dev/null +++ b/resources/js/canvas/environment/Zone.svelte @@ -0,0 +1,32 @@ + + +
    + +
    diff --git a/resources/js/canvas/environment/index.js b/resources/js/canvas/environment/index.js new file mode 100644 index 0000000000..5cb79c380a --- /dev/null +++ b/resources/js/canvas/environment/index.js @@ -0,0 +1,11 @@ +// Environment canvas page mount (initial load and Livewire SPA navigation). +import { mount, unmount } from 'svelte'; +import { mountOnPage } from '../../canvas-core.js'; +import EnvironmentCanvas from './EnvironmentCanvas.svelte'; + +export function initializeEnvironmentCanvas() { + mountOnPage('[data-environment-canvas]', '[data-ec-root]', container => { + const app = mount(EnvironmentCanvas, { target: container, props: { container } }); + return { root: container.querySelector('[data-ec-root]'), unmount: () => unmount(app) }; + }); +} diff --git a/resources/js/deploy-canvas.js b/resources/js/deploy-canvas.js new file mode 100644 index 0000000000..82d28ccd52 --- /dev/null +++ b/resources/js/deploy-canvas.js @@ -0,0 +1,510 @@ +/** + * Deploy canvas: build a deploy workflow (project → server → source → + * settings → deploy) from nodes, run it through Coolify's API controllers + * (at /canvas/api, with the browser session) and follow the deployment logs. + * + * Mounted on the /canvas page into [data-deploy-canvas] by + * canvas/deploy/index.js (Svelte components). This file keeps the DOM-free + * parts: step types, flow rules and storage; `node --test` imports it. + */ + +import { api } from './canvas-core.js'; + +// ── Settings ─────────────────────────────────────────────────────────── +export const LOG_POLL_MS = 2000; // Deployment log refresh (Coolify's own log view uses 2 s too). +export const STORE = 'coolify-canvas'; // localStorage prefix (flow, view). + +// ── Small helpers ────────────────────────────────────────────────────── +// Icons: the app's reicon glyphs (rendered by the page into +// [data-deploy-canvas-icons]), or a few local paths in the same style. +export const LOCAL_ICONS = { + minus: ['M5 12h14'], + fit: ['M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5'], + back: ['M19 12H5', 'M11 6l-6 6 6 6'], // the environment canvas back arrow + fold: ['M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z', 'M9 4v16'], // the sidebar toggle in the navbar +}; +export const uid = () => Math.random().toString(36).slice(2, 10); +const b64 = text => btoa(unescape(encodeURIComponent(text))); +export const load = (key, fallback) => { + try { return JSON.parse(localStorage.getItem(`${STORE}:${key}`)) ?? fallback; } catch { return fallback; } +}; +export const save = (key, value) => localStorage.setItem(`${STORE}:${key}`, JSON.stringify(value)); +export const sleep = ms => new Promise(r => setTimeout(r, ms)); + +export const ICONS = { + start: 'projects', + server: 'servers', + git: 'sources', + image: 'cloud', + file: 'file-content', + layers: 'layers', + db: 'database', + sliders: 'sliders', + variables: 'variables', + globe: 'globe', + deploy: 'play-circle', + x: 'x', + plus: 'plus', + minus: 'minus', + fit: 'fit', + out: 'external-link', + fold: 'fold', + check: 'check', + chevron: 'chevron-down', + info: 'info-circle', +}; + +// Cached option lists for the node fields (key → array; loading state). +// onChange runs when a list loads or fails (the canvas redraws its fields). +export const lists = { cache: {}, pending: {}, errors: {}, onChange: () => {} }; +function options(key, fetcher) { + const { cache, pending, errors } = lists; + if (cache[key]) return cache[key]; + if (!pending[key]) { + pending[key] = fetcher().then(list => { + cache[key] = list; + delete errors[key]; + }).catch(error => { + errors[key] = error.message; + }).finally(() => { + delete pending[key]; + lists.onChange(); + }); + } + return null; +} +// The cache key a select loads from (to show its error). +export function loaderKey(f, d) { + const map = { project: 'projects', environment: `envs:${d.project}`, server: 'servers', destination: `dest:${d.server}`, ghApp: 'gh', ghRepo: `repos:${d.ghApp}`, ghBranch: `br:${d.ghApp}:${d.ghRepo}`, key: 'keys' }; + return map[f.key]; +} +const LOADERS = { + projects: () => options('projects', async () => (await api('GET', '/projects')).map(p => [p.uuid, p.name])), + envs: project => project && options(`envs:${project}`, async () => (await api('GET', `/projects/${project}/environments`)).map(e => [e.uuid, e.name])), + // Build-only servers cannot run resources (as on the New resource page). + servers: () => options('servers', async () => (await api('GET', '/servers')).filter(s => s.settings?.server_role !== 'build').map(s => [s.uuid, `${s.name}${s.is_reachable === false ? ' (unreachable)' : ''}`])), + destinations: server => server && options(`dest:${server}`, async () => (await api('GET', `/servers/${server}/destinations`)).map(d => [d.uuid, `${d.name} · ${d.network}`])), + ghApps: () => options('gh', async () => (await api('GET', '/github-apps')).filter(a => !a.is_public && a.app_id).map(a => [`${a.id}|${a.uuid}`, a.name])), + repos: app => app && options(`repos:${app}`, async () => (await api('GET', `/github-apps/${app.split('|')[0]}/repositories`)).repositories.map(r => [r.full_name, r.full_name])), + branches: (app, repo) => app && repo && options(`br:${app}:${repo}`, async () => (await api('GET', `/github-apps/${app.split('|')[0]}/repositories/${repo}/branches`)).branches.map(b => [b.name, b.name])), + keys: () => options('keys', async () => (await api('GET', '/security/keys')).map(k => [k.uuid, k.name])), +}; + +// ── Node types ───────────────────────────────────────────────────────── +// stage: start → infra → source → config → ship. kind: what a source creates. +// Select options are [value, label, badge?]; the badge shows next to the label. +const BUILD_PACKS = [['railpack', 'Railpack'], ['nixpacks', 'Nixpacks', 'Deprecated'], ['static', 'Static'], ['dockerfile', 'Dockerfile'], ['dockercompose', 'Docker Compose']]; +// The PostgreSQL images of the New resource page (project/new/select.blade.php). +const POSTGRES_IMAGES = [ + ['postgres:18-alpine', 'PostgreSQL 18'], + ['postgres:17-alpine', 'PostgreSQL 17'], + ['postgres:16-alpine', 'PostgreSQL 16'], + ['supabase/postgres:17.4.1.032', 'Supabase PostgreSQL'], + ['postgis/postgis:17-3.5-alpine', 'PostGIS'], + ['pgvector/pgvector:pg18', 'PGVector 18'], + ['pgvector/pgvector:pg17', 'PGVector 17'], +]; +const DATABASES = [['postgresql', 'PostgreSQL'], ['mysql', 'MySQL'], ['mariadb', 'MariaDB'], ['mongodb', 'MongoDB'], ['redis', 'Redis'], ['keydb', 'KeyDB'], ['dragonfly', 'Dragonfly'], ['clickhouse', 'ClickHouse']]; +// One-click service templates ([key, name]), from the page (deploy-canvas.blade.php). +let serviceTemplates = null; +const templateOptions = () => { + if (!serviceTemplates) { + try { serviceTemplates = JSON.parse(document.querySelector('[data-deploy-canvas-services]')?.textContent || '[]'); } catch { serviceTemplates = []; } + } + return serviceTemplates; +}; + +// The logo of a source step: the one resource_logo() (bootstrap/helpers/services.php) and the +// New resource page show for what it creates. Service logos come from the page +// (deploy-canvas.blade.php). Null keeps the step's glyph. +// The page gives versioned URLs (resource_logo_url()), so a changed logo is not loaded from the cache. +let resourceLogos = null; +const resourceLogoUrl = name => { + if (!resourceLogos) { + try { resourceLogos = JSON.parse(globalThis.document?.querySelector('[data-deploy-canvas-resource-logos]')?.textContent || '{}'); } catch { resourceLogos = {}; } + } + return resourceLogos[name] ?? `/svgs/resources/${name}.svg`; +}; +const resourceLogo = (name, hasDark = false) => ({ + src: resourceLogoUrl(name), dark: hasDark ? resourceLogoUrl(`${name}-dark`) : null, fallback: null, padded: false, +}); +const DATABASE_LOGOS = { postgresql: 'postgres' }; +let serviceLogos = null; +const serviceLogo = key => { + if (!serviceLogos) { + try { serviceLogos = JSON.parse(document.querySelector('[data-deploy-canvas-service-logos]')?.textContent || '{}'); } catch { serviceLogos = {}; } + } + const logo = serviceLogos[key]; + return logo ? { src: logo[0], dark: null, fallback: logo[1], padded: true } : null; +}; +export function logoOf(n) { + const d = n.data || {}; + switch (n.type) { + case 'git': + if (d.buildPack === 'dockerfile') return resourceLogo('dockerfile'); + if (d.buildPack === 'dockercompose') return resourceLogo('docker-compose'); + if (d.mode === 'github') return resourceLogo('github-app', true); + return resourceLogo(d.mode === 'key' ? 'deploy-key' : 'public-repo'); + case 'image': return resourceLogo('docker-image'); + case 'dockerfile': return resourceLogo('dockerfile'); + case 'service': return d.mode === 'compose' ? resourceLogo('docker-compose') : (d.type ? serviceLogo(d.type) : null); + case 'database': return d.engine ? resourceLogo(DATABASE_LOGOS[d.engine] ?? d.engine, ['keydb', 'dragonfly'].includes(d.engine)) : null; + default: return null; + } +} + +export const TYPES = { + start: { + title: 'Start', sub: 'Which project and environment', stage: 'start', icon: ICONS.start, + fields: [ + // `add`: a "New …" row at the end of the list creates one and selects it. + { key: 'project', label: 'Project', kind: 'select', load: () => LOADERS.projects(), rerender: true, required: true, + add: { label: 'New project', placeholder: 'Project name', list: () => 'projects', create: async name => (await api('POST', '/projects', { name })).uuid } }, + { key: 'environment', label: 'Environment', kind: 'select', load: d => LOADERS.envs(d.project), required: true, only: true, + add: { label: 'New environment', placeholder: 'Environment name', list: d => `envs:${d.project}`, create: async (name, d) => (await api('POST', `/projects/${d.project}/environments`, { name })).uuid } }, + ], + run: async (ctx, d) => { + ctx.project = d.project; + ctx.environment = d.environment; + return 'Project and environment set.'; + }, + }, + server: { + title: 'Server', sub: 'Where it runs', stage: 'infra', icon: ICONS.server, + fields: [ + { key: 'server', label: 'Server', kind: 'select', load: () => LOADERS.servers(), rerender: true, required: true, only: true }, + { key: 'destination', label: 'Network', kind: 'select', load: d => LOADERS.destinations(d.server), optional: 'Default' }, + ], + run: async (ctx, d) => { + ctx.server = d.server; + ctx.destination = d.destination || undefined; + return 'Server picked.'; + }, + }, + git: { + title: 'Git repository', sub: 'Build from source', stage: 'source', kind: 'application', icon: ICONS.git, + fields: [ + { key: 'mode', label: 'Access', kind: 'segmented', options: [['public', 'Public'], ['github', 'GitHub app'], ['key', 'Deploy key']], rerender: true, value: 'public' }, + { key: 'repository', label: 'Repository URL', kind: 'text', placeholder: 'https://github.com/coollabsio/coolify-examples', show: d => d.mode !== 'github', required: true, secret: v => /^\w+:\/\/[^/]*@/.test(v) }, + { key: 'ghApp', label: 'GitHub app', kind: 'select', load: () => LOADERS.ghApps(), show: d => d.mode === 'github', rerender: true, required: true }, + { key: 'ghRepo', label: 'Repository', kind: 'select', load: d => LOADERS.repos(d.ghApp), show: d => d.mode === 'github', rerender: true, required: true }, + { key: 'key', label: 'Deploy key', kind: 'select', load: () => LOADERS.keys(), show: d => d.mode === 'key', required: true }, + { key: 'branch', label: 'Branch', kind: 'text', placeholder: 'main', value: 'main', show: d => d.mode !== 'github', required: true }, + { key: 'ghBranch', label: 'Branch', kind: 'select', load: d => LOADERS.branches(d.ghApp, d.ghRepo), show: d => d.mode === 'github', required: true }, + { key: 'buildPack', label: 'Build pack', kind: 'select', options: BUILD_PACKS, value: 'railpack', required: true }, + { key: 'ports', label: 'Port', kind: 'text', placeholder: '3000', value: '3000' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + ctx.applicationSource = true; + const base = common(ctx, d); + Object.assign(base, { build_pack: d.buildPack, ports_exposes: d.ports || undefined }); + let res; + if (d.mode === 'github') { + res = await api('POST', '/applications/private-github-app', { ...base, github_app_uuid: d.ghApp.split('|')[1], git_repository: d.ghRepo, git_branch: d.ghBranch }); + } else if (d.mode === 'key') { + res = await api('POST', '/applications/private-deploy-key', { ...base, private_key_uuid: d.key, git_repository: d.repository, git_branch: d.branch }); + } else { + res = await api('POST', '/applications/public', { ...base, git_repository: d.repository, git_branch: d.branch }); + } + ctx.resource = { kind: 'application', sub: 'git', uuid: res.uuid }; + ctx.createdDomains = res.domains && domainList(res.domains); + return 'Application created.'; + }, + }, + image: { + title: 'Docker image', sub: 'Run a prebuilt image', stage: 'source', kind: 'application', icon: ICONS.image, + fields: [ + { key: 'image', label: 'Image', kind: 'text', placeholder: 'nginx', required: true }, + { key: 'tag', label: 'Tag', kind: 'text', placeholder: 'latest, or the tag in the image' }, + { key: 'ports', label: 'Port', kind: 'text', placeholder: '80', value: '80' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + ctx.applicationSource = true; + const { name, tag } = splitImage(d.image, d.tag); + const res = await api('POST', '/applications/dockerimage', { ...common(ctx, d), docker_registry_image_name: name, docker_registry_image_tag: tag, ports_exposes: d.ports || undefined }); + ctx.resource = { kind: 'application', sub: 'image', uuid: res.uuid }; + ctx.createdDomains = res.domains && domainList(res.domains); + return 'Application created.'; + }, + }, + dockerfile: { + title: 'Dockerfile', sub: 'Build from a Dockerfile', stage: 'source', kind: 'application', icon: ICONS.file, + fields: [ + { key: 'dockerfile', label: 'Dockerfile', kind: 'textarea', placeholder: 'FROM nginx:alpine\nCOPY . /usr/share/nginx/html', required: true }, + { key: 'ports', label: 'Port', kind: 'text', placeholder: '80', value: '80' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + ctx.applicationSource = true; + const res = await api('POST', '/applications/dockerfile', { ...common(ctx, d), dockerfile: b64(d.dockerfile), ports_exposes: d.ports || undefined }); + ctx.resource = { kind: 'application', sub: 'dockerfile', uuid: res.uuid }; + ctx.createdDomains = res.domains && domainList(res.domains); + return 'Application created.'; + }, + }, + service: { + title: 'Service', sub: 'One-click template or compose', stage: 'source', kind: 'service', icon: ICONS.layers, + fields: [ + { key: 'mode', label: 'From', kind: 'segmented', options: [['template', 'Template'], ['compose', 'Compose']], rerender: true, value: 'template' }, + { key: 'type', label: 'Template', kind: 'select', load: () => templateOptions(), show: d => d.mode !== 'compose', required: true }, + { key: 'compose', label: 'docker-compose.yml', kind: 'textarea', placeholder: 'services:\n app:\n image: nginx', show: d => d.mode === 'compose', required: true, secret: true }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + const body = common(ctx, d); + if (d.mode === 'compose') body.docker_compose_raw = b64(d.compose); + else body.type = d.type.trim(); + const res = await api('POST', '/services', body); + ctx.resource = { kind: 'service', uuid: res.uuid }; + return 'Service created.'; + }, + }, + database: { + title: 'Database', sub: 'A managed database', stage: 'source', kind: 'database', icon: ICONS.db, + fields: [ + // A new engine starts with its default image (PostgreSQL: PostgreSQL 18). + { key: 'engine', label: 'Engine', kind: 'select', options: DATABASES, value: 'postgresql', required: true, + after: d => { d.image = d.engine === 'postgresql' ? POSTGRES_IMAGES[0][0] : ''; } }, + { key: 'image', label: 'Image', kind: 'select', options: POSTGRES_IMAGES, value: POSTGRES_IMAGES[0][0], show: d => d.engine === 'postgresql', required: true }, + { key: 'image', label: 'Image', kind: 'text', placeholder: 'Default for the engine', show: d => d.engine !== 'postgresql' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + const body = common(ctx, d); + if (d.image) body.image = d.image; + const res = await api('POST', `/databases/${d.engine}`, body); + ctx.resource = { kind: 'database', uuid: res.uuid }; + return 'Database created.'; + }, + }, + build: { + title: 'Build settings', sub: 'Directories and commands', stage: 'config', icon: ICONS.sliders, for: ['git'], + fields: [ + { key: 'base_directory', label: 'Base directory', kind: 'text', placeholder: '/' }, + { key: 'publish_directory', label: 'Publish directory', kind: 'text', placeholder: '/dist' }, + { key: 'install_command', label: 'Install command', kind: 'text', placeholder: 'Detected' }, + { key: 'build_command', label: 'Build command', kind: 'text', placeholder: 'Detected' }, + { key: 'start_command', label: 'Start command', kind: 'text', placeholder: 'Detected' }, + ], + run: async (ctx, d) => { + const body = Object.fromEntries(['base_directory', 'publish_directory', 'install_command', 'build_command', 'start_command'].filter(k => d[k]).map(k => [k, d[k]])); + if (!Object.keys(body).length) return 'Nothing to change.'; + await api('PATCH', `/applications/${ctx.resource.uuid}`, body); + return 'Build settings saved.'; + }, + }, + env: { + title: 'Environment variables', sub: 'KEY=value, one per line', stage: 'config', icon: ICONS.variables, for: ['application', 'service'], + fields: [ + { key: 'vars', label: 'Variables', kind: 'textarea', placeholder: 'NODE_ENV=production\nAPI_URL=https://api.example.com', required: true, mono: true, secret: true }, + ], + run: async (ctx, d) => { + const data = parseEnvVars(d.vars); + if (!data.length) return 'No variables.'; + const kind = ctx.resource.kind === 'service' ? 'services' : 'applications'; + await api('PATCH', `/${kind}/${ctx.resource.uuid}/envs/bulk`, { data }); + return `${data.length} variable${data.length === 1 ? '' : 's'} saved.`; + }, + }, + domain: { + title: 'Domains', sub: 'Your own public addresses', stage: 'config', icon: ICONS.globe, for: ['git', 'image', 'dockerfile'], + fields: [ + { key: 'domains', label: 'Domains', kind: 'textarea', placeholder: 'https://app.example.com', required: true, mono: true, rows: 2 }, + { key: 'note', kind: 'note', text: 'Without this step, Coolify makes an address from the server’s wildcard domain (or sslip.io).' }, + ], + run: async (ctx, d) => { + const domains = domainList(d.domains); + if (ctx.createdDomains === domains) return `Set at creation: ${domains.replace(/,/g, ', ')}.`; + const res = await api('PATCH', `/applications/${ctx.resource.uuid}`, { domains }); + return `Domains saved${res?.uuid ? '' : ''}: ${domains.replace(/,/g, ', ')}.`; + }, + }, + deploy: { + title: 'Deploy', sub: 'Save or deploy every resource', stage: 'ship', icon: ICONS.deploy, + fields: [], + }, +}; +// "nginx:alpine" → nginx + alpine; "ghcr.io:443/a/b@sha256:…" → digest tag +// (Coolify's "sha256-…" form). A tag in the image wins over the Tag field. +export function splitImage(image, tagField) { + let name = String(image || '').trim(); + let tag = ''; + const at = name.indexOf('@sha256:'); + if (at >= 0) { + tag = `sha256-${name.slice(at + 8)}`; + name = name.slice(0, at); + } else { + const colon = name.lastIndexOf(':'); + if (colon > name.lastIndexOf('/')) { + tag = name.slice(colon + 1); + name = name.slice(0, colon); + } + } + return { name, tag: tag || String(tagField || '').trim() || 'latest' }; +} +// Fields common to every create call. Domains from a Domains step later in +// the chain go in at creation (with no generated domain), as Coolify's +// create endpoints accept them. +function common(ctx, d) { + const body = { project_uuid: ctx.project, environment_uuid: ctx.environment, server_uuid: ctx.server, instant_deploy: false }; + if (ctx.destination) body.destination_uuid = ctx.destination; + if (d.name) body.name = d.name; + if (ctx.domains && ctx.applicationSource) { + body.domains = ctx.domains; + body.autogenerate_domain = false; + } + return body; +} +// "KEY=value" lines → [{ key, value }]; skips blanks and # comments, strips quotes. +export function parseEnvVars(text) { + return String(text || '').split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('#') && line.includes('=')).map(line => { + const i = line.indexOf('='); + return { key: line.slice(0, i).trim(), value: line.slice(i + 1).trim().replace(/^(['"])(.*)\1$/, '$2') }; + }); +} +export const domainList = text => String(text || '').split(/[\n,]/).map(v => v.trim()).filter(Boolean).join(','); +export const PALETTE = [ + ['Infrastructure', ['server']], + ['Source', ['git', 'image', 'dockerfile', 'service', 'database']], + ['Configure', ['build', 'env', 'domain']], +]; + +// ── Flow state ───────────────────────────────────────────────────────── +// Every deploy needs a server, so a new canvas has one, already connected +// (id 'server': it cannot be removed, nor its connection to Start). +export const fresh = () => withDeploy({ + nodes: [{ id: 'start', type: 'start', x: 80, y: 160, data: {} }, { id: 'server', type: 'server', x: 456, y: 160, data: {} }], + edges: [{ from: 'start', to: 'server' }], +}); +// A select with `only: true` takes its option when it has exactly one. +export const onlyOption = (list, value) => (!value && list?.length === 1 ? list[0][0] : value); +// Every flow ends in one Deploy step. Resource lines reach it on their own, so +// edges into it (from older saved flows) are dropped. +export function withDeploy(f) { + if (!f.nodes.some(n => n.type === 'deploy')) f.nodes.push({ id: 'deploy', type: 'deploy', x: 1240, y: 160, data: {} }); + const deployIds = new Set(f.nodes.filter(n => n.type === 'deploy').map(n => n.id)); + f.edges = f.edges.filter(e => !deployIds.has(e.to) && !deployIds.has(e.from)); + return f; +} +// The environment that opened the canvas (?project=…&environment=…, from the +// New resource page), or null. Both values must look like UUIDs. +export function targetFrom(search) { + const params = new URLSearchParams(search); + const project = params.get('project'); + const environment = params.get('environment'); + const valid = value => /^[A-Za-z0-9_-]+$/.test(value ?? ''); + return valid(project) && valid(environment) ? { project, environment } : null; +} +// The Start step targets that environment, also in a flow restored from an earlier visit. +export function withTarget(f, t) { + const start = f.nodes.find(n => n.type === 'start'); + if (t && start) start.data = { project: t.project, environment: t.environment }; + return f; +} +// Secret fields (`secret: true`, or a test on the value) stay in memory only; +// they are never written to localStorage. +export function withoutSecrets(f) { + const nodes = f.nodes.map(n => { + const data = { ...n.data }; + for (const { key, secret } of TYPES[n.type]?.fields || []) { + if (secret === true || (typeof secret === 'function' && data[key] && secret(data[key]))) delete data[key]; + } + return { ...n, data }; + }); + return { ...f, nodes }; +} +// The flow is a tree: Start → Servers → one line per resource (source → +// settings…), and every line ends in the one Deploy step. +export const nodeById = (id, f) => f.nodes.find(n => n.id === id); +export const outsOf = (id, f) => f.edges.filter(e => e.from === id).map(e => e.to); +export const nextOf = (id, f) => outsOf(id, f)[0]; +export const prevOf = (id, f) => f.edges.find(e => e.to === id)?.from; +// Start and Server fan out; only Deploy takes more than one line in. +export const multiOut = n => ['start', 'infra'].includes(TYPES[n.type].stage); +// The source node above a node in its line (what the settings apply to). +export function sourceAbove(id, f) { + const seen = new Set(); + for (let cur = id; cur && !seen.has(cur); cur = prevOf(cur, f)) { + seen.add(cur); + const n = nodeById(cur, f); + if (n && TYPES[n.type].stage === 'source') return n; + } + return null; +} +// Can a `type` step come after `prev`? With `target`, also: the link makes no loop. +export function canFollow(prev, type, target, f) { + const t = TYPES[type]; + const p = TYPES[prev.type]; + if (type === 'start' || type === 'deploy' || prev.type === 'deploy') return false; + if (target) { + const seen = new Set(); + for (let cur = prev.id; cur && !seen.has(cur); cur = prevOf(cur, f)) { + if (cur === target.id) return false; + seen.add(cur); + } + } + if (p.stage === 'start') return type === 'server'; + if (p.stage === 'infra') return t.stage === 'source'; + const src = sourceAbove(prev.id, f); + if (!src) return false; + if (t.stage === 'ship') return true; + if (t.stage !== 'config') return false; + const sub = src.type === 'git' || src.type === 'image' || src.type === 'dockerfile' ? src.type : null; + return t.for.includes(TYPES[src.type].kind) || (sub !== null && t.for.includes(sub)); +} +// What a run does: each resource line under each Server under Start. +export function planOf(f) { + const servers = outsOf('start', f).map(id => nodeById(id, f)).filter(n => n?.type === 'server'); + const lines = []; + for (const server of servers) { + for (const id of outsOf(server.id, f)) { + const source = nodeById(id, f); + if (!source || TYPES[source.type].stage !== 'source') continue; + const settings = []; + const seen = new Set([source.id]); + for (let cur = nextOf(source.id, f); cur && !seen.has(cur); cur = nextOf(cur, f)) { + seen.add(cur); + const n = nodeById(cur, f); + if (!n) break; + settings.push(n); + } + lines.push({ server, source, settings, end: settings.at(-1) || source }); + } + } + return { start: nodeById('start', f), servers, lines, deploy: f.nodes.find(n => n.type === 'deploy') || null }; +} +// Source id → label; same labels get a number ("Docker image · nginx #2"). +export function uniqueLabels(sources) { + const labels = sources.map(resourceLabel); + const seen = {}; + return new Map(sources.map((src, i) => { + const label = labels[i]; + if (labels.filter(l => l === label).length < 2) return [src.id, label]; + seen[label] = (seen[label] || 0) + 1; + return [src.id, `${label} #${seen[label]}`]; + })); +} +// "Docker image · nginx": what a source step makes, for lists and logs. +export function resourceLabel(src) { + const d = src.data; + const detail = d.name || { + git: d.mode === 'github' ? d.ghRepo : d.repository, + image: d.image, + service: d.mode === 'compose' ? 'compose' : d.type, + database: d.engine, + }[src.type]; + return detail ? `${TYPES[src.type].title} · ${detail}` : TYPES[src.type].title; +} +export function defaults(type) { + const data = {}; + for (const f of TYPES[type].fields) if (f.value !== undefined) data[f.key] = f.value; + return data; +} +// The Coolify page of a created resource (its deployment, while one runs). +export function resourceHref(ctx) { + const base = `/project/${ctx.project}/environment/${ctx.environment}`; + const kind = { application: 'application', service: 'service', database: 'database' }[ctx.resource.kind]; + return ctx.deployment && kind === 'application' ? `${base}/application/${ctx.resource.uuid}/deployment/${ctx.deployment}` : `${base}/${kind}/${ctx.resource.uuid}`; +} diff --git a/resources/js/deploy-canvas.test.js b/resources/js/deploy-canvas.test.js new file mode 100644 index 0000000000..e4f9be5461 --- /dev/null +++ b/resources/js/deploy-canvas.test.js @@ -0,0 +1,178 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { freeSpot } from './canvas-core.js'; +import { canFollow, domainList, fresh, logoOf, onlyOption, parseEnvVars, planOf, splitImage, targetFrom, uniqueLabels, withDeploy, withoutSecrets, withTarget } from './deploy-canvas.js'; + +test('splits the tag out of an image name', () => { + assert.deepEqual(splitImage('nginx:alpine', 'latest'), { name: 'nginx', tag: 'alpine' }); + assert.deepEqual(splitImage('ghcr.io:443/org/app', ''), { name: 'ghcr.io:443/org/app', tag: 'latest' }); + assert.deepEqual(splitImage('ghcr.io:443/org/app', 'v2'), { name: 'ghcr.io:443/org/app', tag: 'v2' }); +}); + +test('turns an image digest into the sha256- tag form', () => { + assert.deepEqual(splitImage('redis@sha256:abc123', 'latest'), { name: 'redis', tag: 'sha256-abc123' }); +}); + +test('parses KEY=value lines and skips comments and blanks', () => { + assert.deepEqual(parseEnvVars('# comment\n\nNODE_ENV=production\nURL="https://a.b/?x=1"\nNOPE\n'), [ + { key: 'NODE_ENV', value: 'production' }, + { key: 'URL', value: 'https://a.b/?x=1' }, + ]); +}); + +test('joins domains from lines and commas', () => { + assert.equal(domainList(' https://a.com\nhttps://b.com, https://c.com \n'), 'https://a.com,https://b.com,https://c.com'); +}); + +// Start → Server → (Service, Dockerfile → Env), and the one Deploy step. +const node = (id, type, data = {}) => ({ id, type, x: 0, y: 0, data }); +const fanOut = () => ({ + nodes: [ + node('start', 'start'), + node('srv', 'server'), + node('svc', 'service', { mode: 'template', type: 'umami' }), + node('df', 'dockerfile'), + node('env', 'env'), + node('dep', 'deploy'), + ], + edges: [ + { from: 'start', to: 'srv' }, + { from: 'srv', to: 'svc' }, + { from: 'srv', to: 'df' }, + { from: 'df', to: 'env' }, + ], +}); + +test('plans one line per resource under a server, each ending at its last step', () => { + const plan = planOf(fanOut()); + assert.equal(plan.deploy.id, 'dep'); + assert.deepEqual(plan.lines.map((l) => [l.server.id, l.source.id, l.settings.map((n) => n.id), l.end.id]), [ + ['srv', 'svc', [], 'svc'], + ['srv', 'df', ['env'], 'env'], + ]); +}); + +test('adds the deploy step to a flow without one and drops edges into it', () => { + const flow = withDeploy({ + nodes: [node('start', 'start'), node('srv', 'server'), node('img', 'image'), node('old', 'deploy')], + edges: [{ from: 'start', to: 'srv' }, { from: 'srv', to: 'img' }, { from: 'img', to: 'old' }], + }); + assert.deepEqual(flow.edges, [{ from: 'start', to: 'srv' }, { from: 'srv', to: 'img' }]); + assert.equal(flow.nodes.filter((n) => n.type === 'deploy').length, 1); + + const empty = withDeploy({ nodes: [node('start', 'start')], edges: [] }); + assert.equal(empty.nodes.filter((n) => n.type === 'deploy').length, 1); +}); + +test('allows only steps that fit after a step', () => { + const flow = fanOut(); + const byId = (id) => flow.nodes.find((n) => n.id === id); + assert.equal(canFollow(byId('srv'), 'image', null, flow), true); + assert.equal(canFollow(byId('start'), 'image', null, flow), false); + assert.equal(canFollow(byId('env'), 'deploy', byId('dep'), flow), false); // Deploy is connected on its own + assert.equal(canFollow(byId('svc'), 'build', null, flow), false); // build settings are for git only + assert.equal(canFollow(byId('dep'), 'env', null, flow), false); +}); + +test('refuses a link that would make a loop', () => { + const flow = fanOut(); + flow.nodes.push(node('env2', 'env')); + flow.edges.push({ from: 'env', to: 'env2' }); + const byId = (id) => flow.nodes.find((n) => n.id === id); + assert.equal(canFollow(byId('env2'), 'env', byId('env'), flow), false); +}); + +test('numbers resources that would have the same name', () => { + const names = uniqueLabels([ + node('a', 'image', { image: 'nginx' }), + node('b', 'image', { image: 'nginx' }), + node('c', 'service', { mode: 'template', type: 'umami' }), + ]); + assert.deepEqual([...names.values()], ['Docker image · nginx #1', 'Docker image · nginx #2', 'Service · umami']); +}); + +test('keeps secrets out of the stored flow', () => { + const flow = { + nodes: [ + node('env', 'env', { vars: 'TOKEN=abc' }), + node('svc', 'service', { mode: 'compose', compose: 'services: {}', name: 'web' }), + node('git', 'git', { repository: 'https://user:tok@github.com/a/b', branch: 'main' }), + node('pub', 'git', { repository: 'https://github.com/a/b' }), + ], + edges: [], + }; + const stored = withoutSecrets(flow); + assert.deepEqual(stored.nodes.map((n) => n.data), [{}, { mode: 'compose', name: 'web' }, { branch: 'main' }, { repository: 'https://github.com/a/b' }]); + assert.equal(flow.nodes[0].data.vars, 'TOKEN=abc'); // the open canvas keeps them +}); + +test('reads the environment that opened the canvas from the query string', () => { + assert.deepEqual(targetFrom('?project=p1a2&environment=e_3-x'), { project: 'p1a2', environment: 'e_3-x' }); + assert.equal(targetFrom(''), null); + assert.equal(targetFrom('?project=p1a2'), null); + assert.equal(targetFrom('?project=../servers&environment=e1'), null); + assert.equal(targetFrom('?project=p1&environment='), null); +}); + +test('points the Start step of a restored flow at the opening environment', () => { + const flow = { + nodes: [node('start', 'start', { project: 'old', environment: 'old-env' }), node('srv', 'server', { server: 's1' })], + edges: [{ from: 'start', to: 'srv' }], + }; + withTarget(flow, { project: 'p1', environment: 'e1' }); + assert.deepEqual(flow.nodes.map((n) => n.data), [{ project: 'p1', environment: 'e1' }, { server: 's1' }]); + assert.deepEqual(withTarget(flow, null).nodes[0].data, { project: 'p1', environment: 'e1' }); +}); + +test('a new canvas starts with a Server step connected to Start', () => { + const plan = planOf(fresh()); + assert.deepEqual(plan.servers.map((n) => n.id), ['server']); + assert.equal(plan.deploy?.type, 'deploy'); + assert.deepEqual(plan.lines, []); +}); + +test('selects the only option, and only when nothing is selected', () => { + assert.equal(onlyOption([['srv-1', 'localhost']], ''), 'srv-1'); + assert.equal(onlyOption([['srv-1', 'localhost']], undefined), 'srv-1'); + assert.equal(onlyOption([['srv-1', 'localhost']], 'srv-old'), 'srv-old'); + assert.equal(onlyOption([['srv-1', 'a'], ['srv-2', 'b']], ''), ''); + assert.equal(onlyOption([], ''), ''); + assert.equal(onlyOption(null, ''), ''); // still loading +}); + +test('keeps a dragged card off the other cards', () => { + const other = { x: 0, y: 0, w: 100, h: 100 }; + const free = { x: 300, y: 0, w: 100, h: 100 }; + assert.deepEqual(freeSpot(free, [other]), free); + // Overlaps from the right: pushed to the right edge, plus the gap. + assert.deepEqual(freeSpot({ x: 90, y: 10, w: 100, h: 100 }, [other], 16), { x: 116, y: 10, w: 100, h: 100 }); + // Overlaps from below: pushed under it. + assert.deepEqual(freeSpot({ x: 5, y: 80, w: 100, h: 100 }, [other], 16), { x: 5, y: 116, w: 100, h: 100 }); +}); + +test('finds a corner spot when two cards block the way', () => { + const cards = [{ x: 0, y: 0, w: 100, h: 100 }, { x: 116, y: 0, w: 100, h: 100 }]; + const spot = freeSpot({ x: 60, y: 20, w: 100, h: 100 }, cards, 16); + for (const c of cards) { + const apart = spot.x >= c.x + c.w + 16 || spot.x + spot.w + 16 <= c.x || spot.y >= c.y + c.h + 16 || spot.y + spot.h + 16 <= c.y; + assert.ok(apart, 'the spot does not touch a card'); + } + assert.deepEqual(spot, { x: 60, y: 116, w: 100, h: 100 }); +}); + +test('shows the logo of what a source step creates, like the rest of Coolify', () => { + const src = (type, data) => logoOf(node('n', type, data))?.src ?? null; + assert.equal(src('image', {}), '/svgs/resources/docker-image.svg'); + assert.equal(src('dockerfile', {}), '/svgs/resources/dockerfile.svg'); + assert.equal(src('database', { engine: 'postgresql' }), '/svgs/resources/postgres.svg'); + assert.equal(src('database', { engine: 'mysql' }), '/svgs/resources/mysql.svg'); + assert.equal(logoOf(node('n', 'database', { engine: 'keydb' })).dark, '/svgs/resources/keydb-dark.svg'); + assert.equal(src('git', { mode: 'public', buildPack: 'railpack' }), '/svgs/resources/public-repo.svg'); + assert.equal(src('git', { mode: 'github', buildPack: 'railpack' }), '/svgs/resources/github-app.svg'); + assert.equal(src('git', { mode: 'key', buildPack: 'railpack' }), '/svgs/resources/deploy-key.svg'); + assert.equal(src('git', { mode: 'public', buildPack: 'dockerfile' }), '/svgs/resources/dockerfile.svg'); + assert.equal(src('service', { mode: 'compose' }), '/svgs/resources/docker-compose.svg'); + // No engine or template yet, or a step that creates nothing: the step keeps its glyph. + assert.equal(src('database', {}), null); + assert.equal(src('server', {}), null); +}); diff --git a/resources/js/environment-canvas.js b/resources/js/environment-canvas.js new file mode 100644 index 0000000000..ed2f105912 --- /dev/null +++ b/resources/js/environment-canvas.js @@ -0,0 +1,621 @@ +// Environment canvas (Beta): pure helpers and constants. The Svelte +// components live in canvas/environment/. +// +// DOM-free: `node --test` imports this file. + +import { apart, BOX_GAP, MAX_ZOOM, MIN_ZOOM } from './canvas-core.js'; + +export const CARD_WIDTH = 260; +export const COLUMN_GAP = 48; +export const ROW_GAP = 24; +export const ZONE_GAP = 72; +export const ZONE_PADDING = 24; +export const ZONE_HEADER = 40; +export const MAX_CHILDREN = 5; +export const MAX_VOLUMES = 3; +export const MAX_ROWS = 6; // auto placement wraps a type into another column after this many cards +export const NO_SERVER = 'No server'; +export const DOUBLE_CLICK_MS = 400; +export const SOURCES_ZONE = 'sources'; +export const CONNECTED_ZONE = 'connected'; + +const COLUMNS = { application: 0, service: 1, database: 2 }; +/** Connect view: shared variable and tag cards are link sources; every other card is a resource. */ +export const isSource = node => node?.type === 'variable' || node?.type === 'tag'; +const columnOf = node => COLUMNS[node?.type] ?? 0; +const columnLeft = (originX, column) => originX + column * (CARD_WIDTH + COLUMN_GAP); +const isPoint = p => !!p && Number.isFinite(Number(p.x)) && Number.isFinite(Number(p.y)); +const storedPositions = graph => (graph?.positions && !Array.isArray(graph.positions) ? graph.positions : {}); + +// ── Pure helpers (tested) ───────────────────────────────────────────── + +/** Estimated card height in px; matches the rendered card closely enough for auto placement. */ +export function estimateNodeHeight(node) { + let height = 58; // padding + icon/name row + if (node?.urls?.length) height += 20; + if (node?.extra_zones?.length || node?.volumes?.length) height += 28; + const children = node?.children?.length || 0; + if (children) height += 12 + Math.min(children, MAX_CHILDREN) * 20 + (children > MAX_CHILDREN ? 20 : 0); + return height; +} + +/** Zones in display order (graph.zones order, then "No server"), each with its nodes. Empty zones are skipped. */ +export function zoneGroups(graph) { + const zones = graph?.zones || []; + const known = new Set(zones.map(z => z.id)); + const groups = zones.map(zone => ({ id: zone.id, name: zone.name, zone, nodes: [] })); + const none = { id: null, name: NO_SERVER, zone: null, nodes: [] }; + for (const node of graph?.nodes || []) { + if (node.zone !== null && node.zone !== undefined && known.has(node.zone)) { + groups.find(g => g.id === node.zone).nodes.push(node); + } else { + none.nodes.push(node); + } + } + return [...groups, none].filter(g => g.nodes.length); +} + +/** + * Card positions (top-left, world px) for every node. Stored positions win; + * other nodes are placed in their zone: applications left, services middle, + * databases right. Zones without stored cards stack below everything else. + */ +export function layoutGraph(graph) { + if (graph?.layout === 'server') return layoutServerView(graph); + if (graph?.layout === 'connect') return layoutConnectView(graph); + const stored = storedPositions(graph); + const out = {}; + const placedBottom = []; + const fresh = []; + for (const group of zoneGroups(graph)) { + const fixed = group.nodes.filter(n => isPoint(stored[n.id])); + if (!fixed.length) { + fresh.push(group); + continue; + } + for (const n of fixed) out[n.id] = { x: Number(stored[n.id].x), y: Number(stored[n.id].y) }; + const originX = Math.min(...fixed.map(n => out[n.id].x)); + const fixedBottom = Math.max(...fixed.map(n => out[n.id].y + estimateNodeHeight(n))); + // New cards go in a grid below the saved ones, so they never cover a saved card. + const bottom = placeGrid(group.nodes.filter(n => !out[n.id]), originX, fixedBottom + ROW_GAP, out); + placedBottom.push(Math.max(fixedBottom, bottom)); + } + let y = placedBottom.length ? Math.max(...placedBottom) + ZONE_PADDING + ZONE_GAP : 0; + for (const group of fresh) { + y = placeGrid(group.nodes, 0, y + ZONE_HEADER + ZONE_PADDING, out) + ZONE_PADDING + ZONE_GAP; + } + return out; +} + +/** + * Places nodes in a grid from (originX, top): applications, then services, then + * databases, each type in its own column(s) that wrap after MAX_ROWS cards. + * Returns the bottom of the lowest card (top when there are no nodes). + */ +function placeGrid(nodes, originX, top, out) { + let firstColumn = 0; + let bottom = top; + for (const column of [0, 1, 2]) { + const ofType = nodes.filter(n => columnOf(n) === column); + const nextY = []; + ofType.forEach((n, i) => { + const sub = Math.floor(i / MAX_ROWS); + nextY[sub] ??= top; + out[n.id] = { x: columnLeft(originX, firstColumn + sub), y: nextY[sub] }; + nextY[sub] += estimateNodeHeight(n) + ROW_GAP; + }); + firstColumn += nextY.length; + bottom = Math.max(bottom, ...nextY.map(v => v - ROW_GAP)); + } + return bottom; +} + +// ── Server view: project / environment groups, linked cards side by side ── + +export const GROUP_PADDING = 16; +export const GROUP_HEADER = 32; +export const GROUP_GAP = 32; +export const GROUPS_PER_ROW = 3; +export const SINGLE_COLUMNS = 3; + +const contextGroups = nodes => { + const groups = new Map(); + for (const n of nodes) { + const key = n.context || ''; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(n); + } + // Server view: the unmanaged containers group comes first, so it is not lost below many projects. + const unmanaged = ([, members]) => (members[0]?.type === 'container' ? 0 : 1); + return [...groups.entries()].sort((a, b) => unmanaged(a) - unmanaged(b) || a[0].localeCompare(b[0])); +}; +const byTypeAndName = (a, b) => columnOf(a) - columnOf(b) || String(a.name).localeCompare(String(b.name)); + +/** + * Card positions inside one group, from (0, 0). Linked cards form chains from + * left to right (source before target, one column per step); every chain gets + * its own rows. Cards without links follow in a grid of SINGLE_COLUMNS. + */ +export function layoutBlock(nodes, links = []) { + const ids = new Set(nodes.map(n => n.id)); + const inside = links.filter(l => ids.has(l.source) && ids.has(l.target) && l.source !== l.target); + const parent = new Map(nodes.map(n => [n.id, n.id])); + const find = id => (parent.get(id) === id ? id : find(parent.get(id))); + for (const l of inside) parent.set(find(l.source), find(l.target)); + const order = [...nodes].sort(byTypeAndName); + const components = new Map(); + for (const n of order) { + const root = find(n.id); + if (!components.has(root)) components.set(root, []); + components.get(root).push(n); + } + + const out = {}; + const step = CARD_WIDTH + COLUMN_GAP; + let top = 0; + let columns = 0; + const singles = []; + for (const members of components.values()) { + if (members.length === 1) { + singles.push(members[0]); + continue; + } + // Depth: the longest path from a card without incoming links (cycles stop at the size of the chain). + const own = new Set(members.map(n => n.id)); + const edges = inside.filter(l => own.has(l.source)); + const depth = new Map(members.filter(n => !edges.some(l => l.target === n.id)).map(n => [n.id, 0])); + if (!depth.size) depth.set(members[0].id, 0); + for (let changed = true, rounds = 0; changed && rounds < members.length; rounds++) { + changed = false; + for (const l of edges) { + const next = (depth.get(l.source) ?? -1) + 1; + if (next > 0 && next > (depth.get(l.target) ?? -1)) { + depth.set(l.target, next); + changed = true; + } + } + } + const nextY = []; + for (const n of members) { + const d = depth.get(n.id) ?? 0; + nextY[d] ??= top; + out[n.id] = { x: d * step, y: nextY[d] }; + nextY[d] += estimateNodeHeight(n) + ROW_GAP; + } + columns = Math.max(columns, nextY.length); + top = Math.max(...nextY.filter(v => v !== undefined)) + ROW_GAP; + } + let rowHeight = 0; + singles.forEach((n, i) => { + const column = i % SINGLE_COLUMNS; + if (column === 0 && i > 0) { + top += rowHeight + ROW_GAP; + rowHeight = 0; + } + out[n.id] = { x: column * step, y: top }; + rowHeight = Math.max(rowHeight, estimateNodeHeight(n)); + }); + if (singles.length) columns = Math.max(columns, Math.min(singles.length, SINGLE_COLUMNS)); + const bottom = singles.length ? top + rowHeight : Math.max(0, top - ROW_GAP); + return { positions: out, width: columns * step - COLUMN_GAP, height: bottom }; +} + +/** + * A project / environment group with cards saved on its environment canvas: + * the same placement as that canvas (saved cards, then a grid below them), + * moved so the top-left card starts at (0, 0). Groups without saved cards use layoutBlock. + */ +export function layoutStoredBlock(nodes, stored, links = []) { + if (!nodes.some(n => isPoint(stored[n.id]))) return layoutBlock(nodes, links); + const zone = nodes[0].zone; + const placed = layoutGraph({ zones: [{ id: zone }], nodes: nodes.map(n => ({ ...n, zone })), positions: stored }); + const minX = Math.min(...nodes.map(n => placed[n.id].x)); + const minY = Math.min(...nodes.map(n => placed[n.id].y)); + const positions = Object.fromEntries(nodes.map(n => [n.id, { x: placed[n.id].x - minX, y: placed[n.id].y - minY }])); + return { + positions, + origin: { x: minX, y: minY }, + width: Math.max(...nodes.map(n => positions[n.id].x + CARD_WIDTH)), + height: Math.max(...nodes.map(n => positions[n.id].y + estimateNodeHeight(n))), + }; +} + +/** + * Top-left of the given cards where their environment canvas places them. A group saved for the first time + * from the server view goes there, so it does not cover the cards of the environment on other servers. + */ +function environmentOrigin(graph, nodes) { + const environment = nodes[0]?.environment?.uuid; + if (!environment) return { x: 0, y: 0 }; + const placed = layoutGraph({ + zones: graph.zones, + nodes: (graph.nodes || []).filter(n => n.environment?.uuid === environment), + positions: storedPositions(graph), + }); + return { x: Math.min(...nodes.map(n => placed[n.id].x)), y: Math.min(...nodes.map(n => placed[n.id].y)) }; +} + +/** + * Server view: zones stack downwards; inside a zone, one block per project / environment, GROUPS_PER_ROW per row. + * When `offsets` is given, it gets per card the shift from its environment canvas position to its server view + * position, so a moved card can be saved on its environment canvas. + */ +export function layoutServerView(graph, offsets = null) { + const stored = storedPositions(graph); + const out = {}; + let y = 0; + for (const zone of zoneGroups(graph)) { + const innerTop = y + ZONE_HEADER + ZONE_PADDING; + let x = 0; + let rowTop = innerTop; + let rowBottom = innerTop; + contextGroups(zone.nodes).forEach(([, nodes], i) => { + if (i > 0 && i % GROUPS_PER_ROW === 0) { + x = 0; + rowTop = rowBottom + GROUP_GAP; + } + const block = layoutStoredBlock(nodes, stored, graph.links || []); + const origin = offsets ? (block.origin ?? environmentOrigin(graph, nodes)) : null; + for (const [id, p] of Object.entries(block.positions)) { + out[id] = { x: x + GROUP_PADDING + p.x, y: rowTop + GROUP_HEADER + GROUP_PADDING + p.y }; + if (offsets) offsets[id] = { x: out[id].x - origin.x - p.x, y: out[id].y - origin.y - p.y }; + } + x += block.width + GROUP_PADDING * 2 + GROUP_GAP; + rowBottom = Math.max(rowBottom, rowTop + GROUP_HEADER + GROUP_PADDING * 2 + block.height); + }); + y = rowBottom + ZONE_PADDING + ZONE_GAP; + } + return out; +} + +/** + * Shared variables view: variable cards in one column on the left (by name). Resources that use a + * variable sit next to them in one column, grouped by project / environment, so every link runs + * straight from left to right. Other resources follow below in the server view grid. + */ +export function layoutConnectView(graph) { + const out = {}; + const groups = zoneGroups(graph); + const variables = groups.find(g => g.id === SOURCES_ZONE); + const connected = groups.find(g => g.id === CONNECTED_ZONE); + let y = ZONE_HEADER + ZONE_PADDING; + for (const n of [...(variables?.nodes || [])].sort((a, b) => String(a.name).localeCompare(String(b.name)))) { + out[n.id] = { x: 0, y }; + y += estimateNodeHeight(n) + ROW_GAP; + } + const offset = variables ? CARD_WIDTH + ZONE_PADDING * 2 + ZONE_GAP * 3 : 0; + let top = 0; + if (connected) { + y = ZONE_HEADER + ZONE_PADDING; + for (const [, nodes] of contextGroups(connected.nodes)) { + y += GROUP_HEADER + GROUP_PADDING; + for (const n of [...nodes].sort(byTypeAndName)) { + out[n.id] = { x: offset + GROUP_PADDING, y }; + y += estimateNodeHeight(n) + ROW_GAP; + } + y += GROUP_PADDING - ROW_GAP + GROUP_GAP; + } + top = y - GROUP_GAP + ZONE_PADDING + ZONE_GAP; + } + const others = groups.filter(g => g.id !== SOURCES_ZONE && g.id !== CONNECTED_ZONE); + const placed = layoutServerView({ zones: others.map(g => g.zone || { id: g.id }), nodes: others.flatMap(g => g.nodes) }); + for (const [id, p] of Object.entries(placed)) out[id] = { x: p.x + offset, y: p.y + top }; + return out; +} + +/** + * Shared variables view: moves the resources that use a shown variable into the "connected" zone, + * so they show apart from the other resources. Runs after filterGraph and after every link change. + */ +export function groupByUse(graph) { + if (graph?.layout !== 'connect') return graph; + const used = new Set((graph.links || []).map(l => l.target)); + return { + ...graph, + nodes: (graph.nodes || []).map(n => (!isSource(n) && used.has(n.id) ? { ...n, zone: CONNECTED_ZONE } : n)), + }; +} + +/** Frames of the project / environment groups of the given cards (server view). */ +export function groupFrames(nodes, positions, sizes = {}) { + const frames = []; + for (const [name, members] of contextGroups(nodes)) { + const rects = members.map(n => cardRect(n, positions, sizes)).filter(Boolean); + if (!rects.length) continue; + const minX = Math.min(...rects.map(r => r.x)); + const minY = Math.min(...rects.map(r => r.y)); + frames.push({ + name, + link: members[0].context_link, + x: minX - GROUP_PADDING, + y: minY - GROUP_PADDING - GROUP_HEADER, + width: Math.max(...rects.map(r => r.x + r.width)) - minX + GROUP_PADDING * 2, + height: Math.max(...rects.map(r => r.y + r.height)) - minY + GROUP_PADDING * 2 + GROUP_HEADER, + }); + } + return frames; +} + +/** Card rect from positions and measured sizes (falls back to the estimate). */ +export function cardRect(node, positions, sizes = {}) { + const p = positions?.[node.id]; + if (!p) return null; + return { + x: p.x, + y: p.y, + width: sizes[node.id]?.width ?? CARD_WIDTH, + height: sizes[node.id]?.height ?? estimateNodeHeight(node), + }; +} + +/** Frame per zone: bbox of its cards (or of its group frames) + padding, with room for the header on top. */ +export function zoneBoxes(graph, positions, sizes = {}) { + const boxes = []; + for (const group of zoneGroups(graph)) { + const rects = group.nodes.some(n => n.context) + ? groupFrames(group.nodes, positions, sizes) + : group.nodes.map(n => cardRect(n, positions, sizes)).filter(Boolean); + if (!rects.length) continue; + const minX = Math.min(...rects.map(r => r.x)); + const minY = Math.min(...rects.map(r => r.y)); + const maxX = Math.max(...rects.map(r => r.x + r.width)); + const maxY = Math.max(...rects.map(r => r.y + r.height)); + boxes.push({ + id: group.id, + name: group.name, + zone: group.zone, + nodes: group.nodes.map(n => n.id), + x: minX - ZONE_PADDING, + y: minY - ZONE_PADDING - ZONE_HEADER, + width: maxX - minX + ZONE_PADDING * 2, + height: maxY - minY + ZONE_PADDING * 2 + ZONE_HEADER, + }); + } + return boxes; +} + +const toBox = r => ({ x: r.x, y: r.y, w: r.width, h: r.height }); +// A frame reaches `side` past its cards left, right and below, and `top` above them. +const grow = (b, side, top) => ({ x: b.x - side, y: b.y - top, w: b.w + side * 2, h: b.h + side + top }); +const frameAround = (boxes, side, top) => { + const x = Math.min(...boxes.map(b => b.x)); + const y = Math.min(...boxes.map(b => b.y)); + return grow({ x, y, w: Math.max(...boxes.map(b => b.x + b.w)) - x, h: Math.max(...boxes.map(b => b.y + b.h)) - y }, side, top); +}; + +/** + * What a dragged card must keep clear of, for freeSpot(): `others` are the + * other cards and the other zone frames (and, server view, the other project / + * environment frames in its zone), grown by the room the card's own frames + * take around it. `fits` checks the card's own frames, which also span its + * other cards, against those frames. + */ +export function dragRules(graph, positions, sizes, id) { + const node = graph.nodes.find(n => n.id === id); + const own = zoneGroups(graph).find(z => z.nodes.some(n => n.id === id)); + const grouped = !!own?.nodes.some(n => n.context); + const context = n => n.context || ''; + const rects = nodes => nodes.filter(n => n.id !== id).map(n => cardRect(n, positions, sizes)).filter(Boolean).map(toBox); + const groupSide = grouped ? GROUP_PADDING : 0; + const groupTop = grouped ? GROUP_PADDING + GROUP_HEADER : 0; + const side = ZONE_PADDING + groupSide; + const top = ZONE_PADDING + ZONE_HEADER + groupTop; + const zones = zoneBoxes(graph, positions, sizes).filter(z => !z.nodes.includes(id)).map(toBox); + const groups = grouped ? groupFrames(own.nodes.filter(n => context(n) !== context(node)), positions, sizes).map(toBox) : []; + const zoneMates = rects(own?.nodes || []); + const groupMates = grouped ? rects(own.nodes.filter(n => context(n) === context(node))) : []; + // The other box must keep the frame's room on the side that faces the card. + const room = (b, s, t) => ({ x: b.x - s, y: b.y - s, w: b.w + s * 2, h: b.h + s + t }); + return { + others: [...rects(graph.nodes), ...zones.map(z => room(z, side, top)), ...groups.map(g => room(g, groupSide, groupTop))], + fits: box => zones.every(z => apart(frameAround([...zoneMates, box], side, top), z, BOX_GAP)) + && (!grouped || groups.every(g => apart(frameAround([...groupMates, box], groupSide, groupTop), g, BOX_GAP))), + }; +} + +/** + * SVG path between two card rects. Target right of source: right edge → + * left edge; target left of source: left edge → right edge; overlapping + * columns (stacked): bottom → top (or top → bottom when the target is above). + */ +export function linkPath(a, b) { + let side; + let from; + let to; + let c1; + let c2; + if (b.x >= a.x + a.width) { + side = 'right'; + from = { x: a.x + a.width, y: a.y + a.height / 2 }; + to = { x: b.x, y: b.y + b.height / 2 }; + } else if (b.x + b.width <= a.x) { + side = 'left'; + from = { x: a.x, y: a.y + a.height / 2 }; + to = { x: b.x + b.width, y: b.y + b.height / 2 }; + } else if (b.y + b.height / 2 >= a.y + a.height / 2) { + side = 'down'; + from = { x: a.x + a.width / 2, y: a.y + a.height }; + to = { x: b.x + b.width / 2, y: b.y }; + } else { + side = 'up'; + from = { x: a.x + a.width / 2, y: a.y }; + to = { x: b.x + b.width / 2, y: b.y + b.height }; + } + if (side === 'right' || side === 'left') { + const dx = Math.max(40, Math.abs(to.x - from.x) / 2) * (side === 'right' ? 1 : -1); + c1 = { x: from.x + dx, y: from.y }; + c2 = { x: to.x - dx, y: to.y }; + } else { + const dy = Math.max(40, Math.abs(to.y - from.y) / 2) * (side === 'down' ? 1 : -1); + c1 = { x: from.x, y: from.y + dy }; + c2 = { x: to.x, y: to.y - dy }; + } + const mid = { + x: (from.x + 3 * c1.x + 3 * c2.x + to.x) / 8, + y: (from.y + 3 * c1.y + 3 * c2.y + to.y) / 8, + }; + const r = v => Math.round(v * 10) / 10; + const d = `M ${r(from.x)} ${r(from.y)} C ${r(c1.x)} ${r(c1.y)}, ${r(c2.x)} ${r(c2.y)}, ${r(to.x)} ${r(to.y)}`; + return { d, side, from, to, mid }; +} + +const BUSY = ['starting', 'restarting', 'created', 'removing', 'in_progress', 'queued', 'deploying', 'building']; +const BAD = ['exited', 'stopped', 'dead', 'failed', 'error']; + +/** Coolify status string → 'ok' | 'warn' | 'bad' | 'idle' | 'busy'. */ +export function statusTone(status) { + const value = String(status ?? '').trim().toLowerCase(); + if (!value) return 'idle'; + const [state, health = ''] = value.replace(/\)$/, '').split(/\s*[:(]\s*/); + if (BUSY.includes(state)) return 'busy'; + if (BAD.includes(state)) return 'bad'; + if (state === 'degraded') return 'warn'; + if (state === 'running' || state === 'healthy') return health === 'unhealthy' ? 'warn' : 'ok'; + return 'idle'; +} + +/** "running:healthy" → "Running (healthy)". */ +export function statusLabel(status) { + const value = String(status ?? '').trim(); + if (!value) return 'Unknown'; + const [state, health] = value.replace(/\)$/, '').split(/\s*[:(]\s*/); + const word = state.charAt(0).toUpperCase() + state.slice(1).replace(/_/g, ' '); + return health ? `${word} (${health})` : word; +} + +/** + * Connect and server views: projects of the resources, each with its environments, sorted by name. + * @returns {{uuid: string, name: string, environments: {uuid: string, name: string}[]}[]} + */ +export function filterOptions(graph) { + const projects = new Map(); + for (const n of graph?.nodes || []) { + if (!n.project || !n.environment) continue; + if (!projects.has(n.project.uuid)) projects.set(n.project.uuid, { ...n.project, environments: new Map() }); + projects.get(n.project.uuid).environments.set(n.environment.uuid, n.environment); + } + const byName = (a, b) => String(a.name).localeCompare(String(b.name)); + return [...projects.values()] + .map(p => ({ uuid: p.uuid, name: p.name, environments: [...p.environments.values()].sort(byName) })) + .sort(byName); +} + +/** + * Connect and server views: only the resources of the chosen project / environment and their links. + * Variables always stay, so they can be added to the shown resources. + */ +export function filterGraph(graph, filter = {}) { + if (!graph || (!filter.project && !filter.environment)) return graph; + const keep = n => isSource(n) + || ((!filter.project || n.project?.uuid === filter.project) && (!filter.environment || n.environment?.uuid === filter.environment)); + const nodes = (graph.nodes || []).filter(keep); + const ids = new Set(nodes.map(n => n.id)); + return { ...graph, nodes, links: (graph.links || []).filter(l => ids.has(l.source) && ids.has(l.target)) }; +} + +/** + * Connect view: links go from a shared variable (not a deleted one) or a tag to a resource. + * Server view: links are saved on an environment canvas, so both cards must be in the same environment. + */ +export function canConnect(graph, source, target) { + if (graph?.layout === 'server') { + const environment = graph.nodes?.find(n => n.id === source)?.environment?.uuid; + return !!environment && source !== target && graph.nodes.find(n => n.id === target)?.environment?.uuid === environment; + } + if (graph?.layout !== 'connect') return !!source && !!target && source !== target; + const from = graph.nodes?.find(n => n.id === source); + const to = graph.nodes?.find(n => n.id === target); + return isSource(from) && !from.missing && !!to && !isSource(to); +} + +/** A link may be drawn between two different nodes that are not linked yet (either direction). */ +export function canLink(links, source, target) { + if (!source || !target || source === target) return false; + return !(links || []).some(l => (l.source === source && l.target === target) || (l.source === target && l.target === source)); +} + +// ── Card and panel constants (used by the Svelte components) ────────── + +// Application and service events go to the team channel; database events go to the user channel. +export const STATUS_CHANNELS = [['team', ['ApplicationStatusChanged', 'ServiceStatusChanged']], ['user', ['DatabaseStatusChanged']]]; +export const TYPE_LABELS = { application: 'Application', service: 'Service', database: 'Database', container: 'Unmanaged container', variable: 'Shared variable', tag: 'Tag' }; +// Wording of the connect view, per data-mode (shared variables or tags). +export const CONNECT_TEXT = { + variables: { + source: 'Variable', + added: (source, target) => `Added ${source} to ${target}. Redeploy it to use the variable.`, + removed: (source, target) => `Removed ${source} from ${target}. Redeploy it to apply.`, + already: (source, target) => `${target} already uses ${source}.`, + usedBy: 'Used by', + noTargets: 'No resource uses this variable.', + allTargets: 'Every resource already uses this variable.', + port: 'Drag to a resource to add this variable to it', + noSources: 'This resource uses no shared variable of this scope.', + sources: 'Shared variables', + addSources: 'Add shared variables', + searchSources: 'Search variables', + noMoreSources: 'No other shared variable to add.', + note: node => `Adding creates a ${node.name} variable with the value ${node.reference} on the resource. Redeploy or restart the resource to apply changes.`, + viewOnly: 'You can look around. Only team admins can add shared variables to resources.', + empty: 'No shared variables or resources yet', + }, + tags: { + source: 'Tag', + added: (source, target) => `Tagged ${target} with ${source}.`, + removed: (source, target) => `Removed the ${source} tag from ${target}.`, + already: (source, target) => `${target} already has the ${source} tag.`, + usedBy: 'Tagged', + noTargets: 'No resource has this tag.', + allTargets: 'Every resource already has this tag.', + port: 'Drag to a resource to tag it', + noSources: 'This resource has no tag.', + sources: 'Tags', + addSources: 'Add tags', + searchSources: 'Search tags', + noMoreSources: 'No other tag to add. Create tags on the Tags page.', + note: () => 'The deploy webhook of a tag deploys every tagged application and service.', + viewOnly: 'You can look around. Only team admins can tag resources.', + empty: 'No tags or resources yet', + }, +}; +export const TONE_DOT = { + ok: 'bg-emerald-500', + warn: 'bg-warning', + bad: 'bg-red-500', + idle: 'bg-neutral-400 dark:bg-neutral-500', + busy: 'bg-warning', +}; +// Outline icons, drawn from local paths. +export const ICONS = { + application: ['M3 6.5A2.5 2.5 0 0 1 5.5 4h13A2.5 2.5 0 0 1 21 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5z', 'M3 9h18', 'M9.5 13l-2 2 2 2', 'M14.5 13l2 2-2 2'], + service: ['M12 4 3 8.5l9 4.5 9-4.5z', 'M3 12.5l9 4.5 9-4.5', 'M3 16.5l9 4.5 9-4.5'], + database: ['M5 6c0-1.66 3.13-3 7-3s7 1.34 7 3-3.13 3-7 3-7-1.34-7-3z', 'M5 6v12c0 1.66 3.13 3 7 3s7-1.34 7-3V6', 'M5 12c0 1.66 3.13 3 7 3s7-1.34 7-3'], + container: ['M12 3 4 7.5v9L12 21l8-4.5v-9z', 'M4 7.5 12 12l8-4.5', 'M12 12v9'], + server: ['M4 4h16v6H4z', 'M4 14h16v6H4z', 'M7.5 7h.01', 'M7.5 17h.01'], + refresh: ['M20 11a8 8 0 0 0-14.9-3.9', 'M4 4v4h4', 'M4 13a8 8 0 0 0 14.9 3.9', 'M20 20v-4h-4'], + plus: ['M12 5v14', 'M5 12h14'], + minus: ['M5 12h14'], + fit: ['M4 9V4h5', 'M20 9V4h-5', 'M4 15v5h5', 'M20 15v5h-5'], + x: ['M6 6l12 12', 'M18 6 6 18'], + external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4'], + link: ['M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1', 'M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1'], + variable: ['M8 4H7a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2 2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1', 'M16 4h1a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2 2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-1'], + search: ['M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14z', 'M20 20l-4-4'], + tag: ['M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9z', 'M7.5 7.5h.01'], + info: ['M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z', 'M12 11v5', 'M12 8h.01'], +}; + +/** A saved { x, y, z } canvas view, or null when it is missing or not valid. */ +export function readView(key, storage = globalThis.localStorage) { + try { + const v = JSON.parse(storage?.getItem(key) ?? 'null'); + const ok = v && [v.x, v.y, v.z].every(Number.isFinite) && v.z >= MIN_ZOOM && v.z <= MAX_ZOOM; + return ok ? { x: v.x, y: v.y, z: v.z } : null; + } catch { + return null; + } +} + +// Small screens: a notice that the canvas is not optimized for mobile. Shown below 768px (md); +// closed once, it stays closed on every canvas (same key as the deploy canvas notice). +export const MOBILE_NOTICE_KEY = 'coolify-canvas:mobile-notice-dismissed'; diff --git a/resources/js/environment-canvas.test.js b/resources/js/environment-canvas.test.js new file mode 100644 index 0000000000..5d7c096be9 --- /dev/null +++ b/resources/js/environment-canvas.test.js @@ -0,0 +1,437 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + CARD_WIDTH, + MAX_ROWS, + COLUMN_GAP, + ROW_GAP, + ZONE_HEADER, + ZONE_PADDING, + canConnect, + canLink, + dragRules, + filterGraph, + groupByUse, + filterOptions, + groupFrames, + layoutBlock, + estimateNodeHeight, + layoutGraph, + layoutServerView, + linkPath, + statusLabel, + statusTone, + zoneBoxes, + readView, +} from './environment-canvas.js'; +import { freeSpot } from './canvas-core.js'; + +const node = (id, type, zone, extra = {}) => ({ id, type, zone, name: id, status: 'running:healthy', extra_zones: [], urls: [], children: [], volumes: [], ...extra }); +const graph = (nodes, extra = {}) => ({ + zones: [{ id: 's1', name: 'One' }, { id: 's2', name: 'Two' }], + nodes, + links: [], + positions: {}, + ...extra, +}); +const column = n => n * (CARD_WIDTH + COLUMN_GAP); + +test('places applications, services, and databases in their own columns', () => { + const p = layoutGraph(graph([node('db', 'database', 's1'), node('app', 'application', 's1'), node('svc', 'service', 's1')])); + assert.equal(p.app.x, column(0)); + assert.equal(p.svc.x, column(1)); + assert.equal(p.db.x, column(2)); + const top = ZONE_HEADER + ZONE_PADDING; + assert.deepEqual([p.app.y, p.svc.y, p.db.y], [top, top, top]); +}); + +test('stacks rows of the same type inside a column', () => { + const first = node('a1', 'application', 's1', { urls: ['https://a.test'] }); + const p = layoutGraph(graph([first, node('a2', 'application', 's1')])); + assert.equal(p.a2.x, p.a1.x); + assert.equal(p.a2.y, p.a1.y + estimateNodeHeight(first) + ROW_GAP); +}); + +test('wraps a long list into another column and shifts later types right', () => { + const apps = Array.from({ length: MAX_ROWS + 1 }, (_, i) => node(`a${i}`, 'application', 's1')); + const p = layoutGraph(graph([...apps, node('db', 'database', 's1')])); + assert.equal(p[`a${MAX_ROWS - 1}`].x, column(0)); + assert.deepEqual(p[`a${MAX_ROWS}`], { x: column(1), y: ZONE_HEADER + ZONE_PADDING }); + assert.equal(p.db.x, column(2)); +}); + +test('stacks zones in graph order with the "No server" zone last', () => { + const p = layoutGraph(graph([node('orphan', 'application', null), node('b', 'application', 's2'), node('a', 'application', 's1')])); + assert.ok(p.a.y < p.b.y, 'first server zone on top'); + assert.ok(p.b.y < p.orphan.y, 'no-server zone at the bottom'); + const boxes = zoneBoxes(graph([node('orphan', 'application', null), node('b', 'application', 's2'), node('a', 'application', 's1')]), p); + assert.deepEqual(boxes.map(b => b.name), ['One', 'Two', 'No server']); + for (let i = 1; i < boxes.length; i++) assert.ok(boxes[i].y > boxes[i - 1].y + boxes[i - 1].height, 'zones do not overlap'); +}); + +test('treats a zone id that is not in the zone list as no server', () => { + const g = graph([node('x', 'database', 'gone')]); + assert.deepEqual(zoneBoxes(g, layoutGraph(g)).map(b => b.id), [null]); +}); + +test('stored positions win over auto placement', () => { + const p = layoutGraph(graph([node('app', 'application', 's1')], { positions: { app: { x: 500, y: -40 } } })); + assert.deepEqual(p.app, { x: 500, y: -40 }); +}); + +test('treats an empty positions list from PHP as nothing stored', () => { + const p = layoutGraph(graph([node('app', 'application', 's1')], { positions: [] })); + assert.equal(p.app.x, 0); +}); + +test('places new nodes in a wrapped grid below the stored cards of their zone', () => { + const stored = node('a1', 'application', 's1'); + const apps = Array.from({ length: MAX_ROWS + 1 }, (_, i) => node(`n${i}`, 'application', 's1')); + const p = layoutGraph(graph([stored, ...apps, node('d1', 'database', 's1')], { positions: { a1: { x: 100, y: 200 } } })); + const top = 200 + estimateNodeHeight(stored) + ROW_GAP; + assert.deepEqual(p.n0, { x: 100, y: top }); + assert.deepEqual(p[`n${MAX_ROWS}`], { x: 100 + column(1), y: top }, 'wraps after MAX_ROWS cards'); + assert.deepEqual(p.d1, { x: 100 + column(2), y: top }, 'databases go right of the application columns'); +}); + +test('auto-placed zones go below zones with stored cards', () => { + const p = layoutGraph(graph([node('a', 'application', 's1'), node('b', 'application', 's2')], { positions: { a: { x: 0, y: 1000 } } })); + assert.ok(p.b.y > 1000 + estimateNodeHeight(node('a', 'application', 's1')) + ZONE_PADDING + ZONE_HEADER); +}); + +test('estimates taller cards for urls, chips, and children', () => { + const plain = estimateNodeHeight(node('a', 'service', 's1')); + const rich = estimateNodeHeight(node('b', 'service', 's1', { urls: ['https://x'], volumes: ['v'], children: [{ name: 'c' }, { name: 'd' }] })); + assert.ok(rich > plain); + const capped = estimateNodeHeight(node('c', 'service', 's1', { children: Array.from({ length: 20 }, (_, i) => ({ name: `c${i}` })) })); + const six = estimateNodeHeight(node('d', 'service', 's1', { children: Array.from({ length: 6 }, (_, i) => ({ name: `c${i}` })) })); + assert.equal(capped, six, 'children beyond the cap add one "+N more" row'); +}); + +test('zone boxes wrap their cards with padding and header', () => { + const g = graph([node('a', 'application', 's1'), node('d', 'database', 's1')]); + const positions = { a: { x: 0, y: 0 }, d: { x: 400, y: 50 } }; + const sizes = { a: { width: 260, height: 100 }, d: { width: 260, height: 80 } }; + const [box] = zoneBoxes(g, positions, sizes); + assert.deepEqual( + { id: box.id, x: box.x, y: box.y, width: box.width, height: box.height, nodes: box.nodes }, + { id: 's1', x: -ZONE_PADDING, y: -ZONE_PADDING - ZONE_HEADER, width: 660 + ZONE_PADDING * 2, height: 130 + ZONE_PADDING * 2 + ZONE_HEADER, nodes: ['a', 'd'] }, + ); +}); + +test('zone boxes skip zones without cards', () => { + assert.deepEqual(zoneBoxes(graph([node('a', 'application', 's2')]), { a: { x: 0, y: 0 } }).map(b => b.id), ['s2']); +}); + +const rect = (x, y) => ({ x, y, width: 260, height: 100 }); + +test('links go from the right edge to the left edge when the target is on the right', () => { + const path = linkPath(rect(0, 0), rect(400, 200)); + assert.equal(path.side, 'right'); + assert.deepEqual(path.from, { x: 260, y: 50 }); + assert.deepEqual(path.to, { x: 400, y: 250 }); + assert.match(path.d, /^M 260 50 C /); +}); + +test('links go from the left edge to the right edge when the target is on the left', () => { + const path = linkPath(rect(400, 0), rect(0, 0)); + assert.equal(path.side, 'left'); + assert.deepEqual(path.from, { x: 400, y: 50 }); + assert.deepEqual(path.to, { x: 260, y: 50 }); +}); + +test('links run vertically between stacked cards', () => { + const down = linkPath(rect(0, 0), rect(40, 300)); + assert.equal(down.side, 'down'); + assert.deepEqual([down.from, down.to], [{ x: 130, y: 100 }, { x: 170, y: 300 }]); + const up = linkPath(rect(0, 300), rect(0, 0)); + assert.equal(up.side, 'up'); + assert.deepEqual([up.from, up.to], [{ x: 130, y: 300 }, { x: 130, y: 100 }]); +}); + +test('link midpoint sits between both ends', () => { + const { mid } = linkPath(rect(0, 0), rect(600, 0)); + assert.deepEqual(mid, { x: 430, y: 50 }); +}); + +test('maps Coolify statuses to tones', () => { + const cases = { + 'running:healthy': 'ok', + 'running:unknown': 'ok', + running: 'ok', + 'running (healthy)': 'ok', + 'running:unhealthy': 'warn', + 'degraded:unhealthy': 'warn', + 'exited:unhealthy': 'bad', + exited: 'bad', + stopped: 'bad', + restarting: 'busy', + 'starting:unknown': 'busy', + paused: 'idle', + '': 'idle', + null: 'idle', + }; + for (const [status, tone] of Object.entries(cases)) { + assert.equal(statusTone(status === 'null' ? null : status), tone, status); + } +}); + +test('formats status labels', () => { + assert.equal(statusLabel('running:healthy'), 'Running (healthy)'); + assert.equal(statusLabel('exited'), 'Exited'); + assert.equal(statusLabel(''), 'Unknown'); +}); + +test('refuses self links and duplicates in either direction', () => { + const links = [{ id: 'l1', source: 'a', target: 'b', label: null }]; + assert.equal(canLink(links, 'a', 'a'), false); + assert.equal(canLink(links, 'a', 'b'), false); + assert.equal(canLink(links, 'b', 'a'), false); + assert.equal(canLink(links, 'a', 'c'), true); +}); + +test('a server view card has the same height as on the environment canvas', () => { + const card = { id: 'a', type: 'application' }; + // The group frame names the project / environment, so the card has no extra line for it. + assert.equal(estimateNodeHeight({ ...card, context: 'Acme / production' }), estimateNodeHeight(card)); +}); + +// ── Server view ── +const srv = (id, type, context, extra = {}) => ({ id, type, name: id, zone: 'z1', context, ...extra }); + +test('server view: a chain of linked cards runs left to right, source before target', () => { + const nodes = [srv('db', 'database', 'A / prod'), srv('web', 'application', 'A / prod'), srv('api', 'application', 'A / prod')]; + const { positions } = layoutBlock(nodes, [{ source: 'web', target: 'api' }, { source: 'api', target: 'db' }]); + assert.equal(positions.web.y, positions.api.y); + assert.equal(positions.api.y, positions.db.y); + assert.ok(positions.web.x < positions.api.x && positions.api.x < positions.db.x); + assert.equal(positions.api.x - positions.web.x, CARD_WIDTH + COLUMN_GAP); +}); + +test('server view: cards without links go below the chains, in a grid', () => { + const nodes = [srv('a', 'application', 'g'), srv('b', 'application', 'g'), srv('c', 'service', 'g'), srv('d', 'database', 'g'), srv('e', 'database', 'g')]; + const { positions } = layoutBlock(nodes, [{ source: 'a', target: 'd' }]); + assert.equal(positions.a.y, positions.d.y); // linked pair side by side + for (const id of ['b', 'c', 'e']) assert.ok(positions[id].y > positions.a.y, `${id} is below the chain`); + assert.equal(positions.b.y, positions.c.y); // one grid row +}); + +test('server view: a link cycle still gets every card a position', () => { + const nodes = [srv('x', 'application', 'g'), srv('y', 'application', 'g')]; + const { positions } = layoutBlock(nodes, [{ source: 'x', target: 'y' }, { source: 'y', target: 'x' }]); + assert.ok(positions.x && positions.y); + assert.notDeepEqual(positions.x, positions.y); +}); + +test('server view: one frame per project / environment, and the frames do not overlap', () => { + const graph = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'application', 'Blog / prod')], + links: [{ id: 'l', source: 'a', target: 'b' }], + positions: {}, + }; + const positions = layoutGraph(graph); + const frames = groupFrames(graph.nodes, positions); + assert.deepEqual(frames.map(f => f.name), ['Blog / prod', 'Shop / prod']); + const [f1, f2] = frames; + assert.ok(f1.x + f1.width <= f2.x || f2.x + f2.width <= f1.x || f1.y + f1.height <= f2.y || f2.y + f2.height <= f1.y); + assert.equal(positions.a.y, positions.b.y); +}); + +test('server view: a group keeps the arrangement saved on its environment canvas', () => { + const graph = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'service', 'Shop / prod')], + links: [], + positions: { a: { x: 900, y: 500 }, b: { x: 600, y: 800 } }, + }; + const positions = layoutGraph(graph); + assert.equal(positions.a.x - positions.b.x, 300); + assert.equal(positions.b.y - positions.a.y, 300); + assert.ok(positions.c.y >= positions.b.y + estimateNodeHeight(graph.nodes[1]), 'a card without a saved position goes below'); +}); + +const envNode = (id, type, zone, environment, extra = {}) => srv(id, type, `Shop / ${environment}`, { zone, project: { uuid: 'p' }, environment: { uuid: environment }, ...extra }); + +test('server view: cards link only to cards of the same environment', () => { + const g = { + layout: 'server', + nodes: [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'database', 'z2', 'prod'), envNode('c', 'application', 'z1', 'dev'), { id: 'container:1', type: 'container', zone: 'z1' }], + }; + assert.equal(canConnect(g, 'a', 'b'), true); + assert.equal(canConnect(g, 'a', 'c'), false); + assert.equal(canConnect(g, 'a', 'a'), false); + assert.equal(canConnect(g, 'a', 'container:1'), false); + assert.equal(canConnect(g, 'container:1', 'a'), false); +}); + +test('server view: a moved group saved on its environment canvas keeps its arrangement', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }, { id: 'z2', name: 'db' }], + nodes: [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'database', 'z1', 'prod'), envNode('c', 'service', 'z2', 'prod'), envNode('d', 'application', 'z1', 'dev')], + links: [{ id: 'l', source: 'a', target: 'b' }], + positions: {}, + }; + const offsets = {}; + const positions = layoutServerView(g, offsets); + // The user drags b 100px down; the canvas saves its group (a, b) in environment canvas coordinates. + positions.b = { x: positions.b.x, y: positions.b.y + 100 }; + const saved = Object.fromEntries(['a', 'b'].map(id => [id, { x: positions[id].x - offsets[id].x, y: positions[id].y - offsets[id].y }])); + const again = layoutServerView({ ...g, positions: saved }); + assert.deepEqual(again.a, positions.a); + assert.deepEqual(again.b, positions.b); + // On the environment canvas the saved cards do not cover the card of the same environment on the other server. + const environment = layoutGraph({ zones: g.zones, nodes: g.nodes.filter(n => n.environment.uuid === 'prod'), positions: saved }); + const bottom = Math.max(...['a', 'b'].map(id => environment[id].y + estimateNodeHeight(g.nodes.find(n => n.id === id)))); + assert.ok(environment.c.y > bottom); +}); + +test('variables view: variables stack on the left by name, resources sit to their right', () => { + const g = { + layout: 'connect', + zones: [{ id: 'sources', name: 'Team variables', icon: 'variable' }, { id: 'resources', name: 'Resources', icon: 'service' }], + nodes: [ + node('variable:2', 'variable', 'sources', { name: 'TOKEN' }), + node('variable:1', 'variable', 'sources', { name: 'API_URL' }), + node('app', 'application', 'resources', { context: 'Shop / prod' }), + ], + links: [{ id: 'variable:1|app', source: 'variable:1', target: 'app' }], + positions: {}, + }; + const p = layoutGraph(g); + + assert.equal(p['variable:1'].x, 0); + assert.equal(p['variable:2'].x, 0); + assert.ok(p['variable:1'].y < p['variable:2'].y); + assert.ok(p.app.x > CARD_WIDTH + ZONE_PADDING * 2); + const [variables, resources] = zoneBoxes(g, p); + assert.ok(variables.x + variables.width < resources.x, 'zones do not overlap'); +}); + +test('variables view: links only go from an existing shared variable to a resource', () => { + const g = { + layout: 'connect', + nodes: [ + node('variable:1', 'variable', 'sources'), + node('variable:2', 'variable', 'sources'), + node('missing:OLD', 'variable', 'sources', { missing: true }), + node('app', 'application', 'resources'), + ], + }; + + assert.equal(canConnect(g, 'variable:1', 'app'), true); + assert.equal(canConnect(g, 'app', 'variable:1'), false); + assert.equal(canConnect(g, 'variable:1', 'variable:2'), false); + assert.equal(canConnect(g, 'missing:OLD', 'app'), false); + assert.equal(canConnect({ nodes: g.nodes }, 'app', 'variable:1'), true, 'other canvases link any two cards'); +}); + +test('variables view: filter resources by project and environment, keep every variable', () => { + const shop = { uuid: 'p1', name: 'Shop' }; + const blog = { uuid: 'p2', name: 'Blog' }; + const g = { + layout: 'connect', + nodes: [ + node('variable:1', 'variable', 'sources'), + node('a', 'application', 'resources', { project: shop, environment: { uuid: 'e1', name: 'production' } }), + node('b', 'application', 'resources', { project: shop, environment: { uuid: 'e2', name: 'staging' } }), + node('c', 'application', 'resources', { project: blog, environment: { uuid: 'e3', name: 'production' } }), + ], + links: [{ id: 'l1', source: 'variable:1', target: 'a' }, { id: 'l2', source: 'variable:1', target: 'c' }], + }; + + assert.deepEqual(filterOptions(g).map(p => [p.name, p.environments.map(e => e.name)]), [['Blog', ['production']], ['Shop', ['production', 'staging']]]); + assert.equal(filterGraph(g, {}), g); + const shopOnly = filterGraph(g, { project: 'p1' }); + assert.deepEqual(shopOnly.nodes.map(n => n.id), ['variable:1', 'a', 'b']); + assert.deepEqual(shopOnly.links.map(l => l.id), ['l1']); + assert.deepEqual(filterGraph(g, { environment: 'e2' }).nodes.map(n => n.id), ['variable:1', 'b']); + assert.deepEqual(filterGraph(g, { environment: 'e2' }).links, []); +}); + +test('variables view: resources that use a variable get their own zone, in one column', () => { + const ctx = { context: 'Shop / prod' }; + const g = groupByUse({ + layout: 'connect', + zones: [{ id: 'sources', icon: 'variable' }, { id: 'connected', icon: 'link' }, { id: 'resources', icon: 'service' }], + nodes: [ + node('variable:1', 'variable', 'sources'), + node('app', 'application', 'resources', ctx), + node('db', 'database', 'resources', ctx), + node('idle', 'application', 'resources', ctx), + ], + links: [{ id: 'l1', source: 'variable:1', target: 'app' }, { id: 'l2', source: 'variable:1', target: 'db' }], + }); + assert.deepEqual(g.nodes.map(n => n.zone), ['sources', 'connected', 'connected', 'resources']); + + const p = layoutGraph(g); + assert.equal(p.app.x, p.db.x, 'connected cards share one column, so no link runs behind a card'); + assert.ok(p.db.y > p.app.y); + const boxes = zoneBoxes(g, p); + const connected = boxes.find(b => b.id === 'connected'); + const others = boxes.find(b => b.id === 'resources'); + assert.ok(connected.y + connected.height < others.y, 'other resources sit below the connected zone'); +}); + +test('readView restores a saved zoom and pan, and ignores bad data', () => { + const store = values => ({ getItem: key => values[key] ?? null }); + assert.deepEqual(readView('k', store({ k: '{"x":-120.5,"y":40,"z":0.6}' })), { x: -120.5, y: 40, z: 0.6 }); + assert.equal(readView('k', store({})), null); + assert.equal(readView('k', store({ k: 'not json' })), null); + assert.equal(readView('k', store({ k: '{"x":1,"y":2}' })), null); + assert.equal(readView('k', store({ k: '{"x":1,"y":2,"z":9}' })), null); +}); + +test('server view: the unmanaged containers group comes first in its zone', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [ + node('a', 'application', 'z1', { context: 'Alpha / prod' }), + node('container:1', 'container', 'z1', { context: 'Unmanaged containers' }), + ], + links: [], + positions: {}, + }; + const positions = layoutServerView(g); + assert.ok(positions['container:1'].x < positions.a.x); +}); + +const apart = (a, b) => a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y; +// Drag card `id` to the top-left of `onto`, as the canvas does: stop at the nearest free spot. +const dragOnto = (g, positions, id, onto) => { + const { others, fits } = dragRules(g, positions, {}, id); + const card = g.nodes.find(n => n.id === id); + const start = { ...positions[id], w: CARD_WIDTH, h: estimateNodeHeight(card) }; + assert.deepEqual(freeSpot(start, others, undefined, fits), start, 'the laid out card is already free, so it does not jump'); + const spot = freeSpot({ ...start, ...positions[onto] }, others, undefined, fits); + return { ...positions, [id]: { x: spot.x, y: spot.y } }; +}; + +test('a dragged card stops before it covers a card or another zone', () => { + const g = graph([node('a', 'application', 's1'), node('b', 'database', 's1'), node('c', 'application', 's2')]); + const before = layoutGraph(g); + for (const [id, onto] of [['a', 'b'], ['a', 'c'], ['c', 'a']]) { + const positions = dragOnto(g, before, id, onto); + const rects = Object.fromEntries(g.nodes.map(n => [n.id, ({ ...positions[n.id], width: CARD_WIDTH, height: estimateNodeHeight(n) })])); + for (const n of g.nodes) if (n.id !== id) assert.ok(apart(rects[id], rects[n.id]), `${id} does not cover ${n.id}`); + const [z1, z2] = zoneBoxes(g, positions); + assert.ok(apart(z1, z2), 'the zones do not overlap'); + } +}); + +test('server view: a dragged card stops before its group covers another group', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'application', 'Blog / prod')], + links: [], + positions: {}, + }; + const positions = dragOnto(g, layoutGraph(g), 'a', 'c'); + const [f1, f2] = groupFrames(g.nodes, positions); + assert.ok(apart(f1, f2), 'the project / environment frames do not overlap'); +}); diff --git a/resources/views/components/canvas-view-toggle.blade.php b/resources/views/components/canvas-view-toggle.blade.php new file mode 100644 index 0000000000..d3db4e10d0 --- /dev/null +++ b/resources/views/components/canvas-view-toggle.blade.php @@ -0,0 +1,51 @@ +@props(['listUrl', 'canvasUrl', 'onCanvas' => false, 'views' => null]) +@php + $item = 'flex size-7.5 items-center justify-center rounded-md transition-colors'; + $idle = 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'; + $canvasItem = 'relative flex h-7.5 items-center gap-1.5 rounded-md px-2.5 text-[12px] font-medium transition-colors'; + $views ??= ['table' => ['Table view', 'unordered-list'], 'grid' => ['Grid view', 'grid']]; + // One list view (no table / grid choice): the list page shows it selected. + $singleList = count($views) === 1; +@endphp +{{-- Table / grid / canvas switcher. On the list page, table and grid switch the + parent's Alpine viewMode; on the canvas page they link back to the list + (?view= makes the list the remembered view again, see RemembersCanvasView). + Pages with one list view pass :views="['list' => [...]]". --}} +
    merge(['class' => 'view-toggle']) }}> + @foreach ($views as $mode => [$label, $icon]) + @if ($onCanvas) + + + + @elseif ($singleList) + + + + @else + + @endif + @endforeach + @if ($onCanvas) + + + Canvas + + @else + {{-- A label and a purple "Try it" badge, so people notice the canvas view. --}} + + + Canvas + + Try it + + + @endif +
    diff --git a/resources/views/components/dashboard/navbar.blade.php b/resources/views/components/dashboard/navbar.blade.php index db6242f56a..8e7e390b81 100644 --- a/resources/views/components/dashboard/navbar.blade.php +++ b/resources/views/components/dashboard/navbar.blade.php @@ -109,7 +109,7 @@ @if ($showNav) - +
    @if ($currentRoute === 'project.service.configuration') @@ -132,25 +136,7 @@

    @elseif ($currentRoute === 'project.service.scheduled-tasks.show') - @elseif ($currentRoute === 'project.service.scheduled-tasks') - @elseif ($currentRoute === 'project.service.webhooks') @elseif ($currentRoute === 'project.service.resource-operations') diff --git a/resources/views/livewire/project/service/index.blade.php b/resources/views/livewire/project/service/index.blade.php index 268eaae499..76e9cb4c20 100644 --- a/resources/views/livewire/project/service/index.blade.php +++ b/resources/views/livewire/project/service/index.blade.php @@ -7,11 +7,11 @@ @if (! $embedded && $resourceType === 'database') @elseif (! $embedded) - + @endif
    @if ($resourceType === 'application') diff --git a/resources/views/livewire/project/service/volume-backup/index.blade.php b/resources/views/livewire/project/service/volume-backup/index.blade.php index acde772a26..ddd7228de2 100644 --- a/resources/views/livewire/project/service/volume-backup/index.blade.php +++ b/resources/views/livewire/project/service/volume-backup/index.blade.php @@ -76,7 +76,7 @@ - @foreach (['general', 's3', 'retention', 'danger'] as $modalSection) + @foreach (['general', 'danger'] as $modalSection)
    @if ($selectedDatabaseBackup) -
    -
    - @if ($type === 'application') - - @elseif ($type === 'service') - - @else - - @endif -
    +
    + @if ($type === 'application') + + @elseif ($type === 'service') + + @else + + @endif
    + @if ($selectedTask) + + + + + + @endif + @@ -32,13 +41,10 @@ 'running' => 'Running', default => 'Not run yet', }; - $taskRoute = $resource->type() === 'application' - ? 'project.application.scheduled-tasks' - : 'project.service.scheduled-tasks'; @endphp - + @empty -
    +
    + + + - @if ($resource->isRunning()) @can('update', $resource) @@ -16,13 +28,6 @@ {{ $isEnabled ? 'Disable' : 'Enable' }} - @endcan @@ -47,8 +52,28 @@ - + + @can('update', $resource) +
    + + +

    The task stops running and cannot be restored.

    + + + +
    +
    +
    + @endcan
    diff --git a/resources/views/livewire/project/shared/storages/volume-backups.blade.php b/resources/views/livewire/project/shared/storages/volume-backups.blade.php index cc354e88d5..052aa48f5e 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups.blade.php @@ -1,9 +1,22 @@ -
    - @if ($section === 'retention') - @include('livewire.project.shared.storages.volume-backups.retention') - @elseif ($section === 's3') - @include('livewire.project.shared.storages.volume-backups.s3') - @elseif ($section === 'executions') +
    + @if (count($pendingDownloads) > 0) + + @endif + @if ($actionsInHeader && $backup) + @can('update', $resource) + @teleport('#resource-action-hud-slot') + + @endteleport + @endcan + @endif + @if ($section === 'executions') @include('livewire.project.shared.storages.volume-backups.executions') @elseif ($section === 'danger') @include('livewire.project.shared.storages.volume-backups.danger') @@ -14,7 +27,11 @@ @script @endscript diff --git a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php index 041fb4470d..dabc86e905 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php @@ -137,7 +137,7 @@ - @if ($execution->status === 'success' && ! $execution->local_storage_deleted) + @if ($execution->status === 'success' && $execution->isDownloadable()) - -
    -
    +
    diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index 9567a3f7fa..28f419d03c 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -34,7 +34,7 @@ 'label' => 'Danger Zone', 'route' => 'source.github.danger', 'active' => $activeTab === 'danger', - 'icon' => 'shield-alert', + 'icon' => 'danger', ], ]; @endphp @@ -47,12 +47,14 @@ @if ($showSettingsSidebar)
    - +
    @if (!data_get($github_app, 'installation_id') && $activeTab === 'general') diff --git a/resources/views/livewire/storage/index.blade.php b/resources/views/livewire/storage/index.blade.php index 30f492705e..ddb23ab6ea 100644 --- a/resources/views/livewire/storage/index.blade.php +++ b/resources/views/livewire/storage/index.blade.php @@ -44,7 +44,7 @@ matches(values) { const query = this.search.trim().toLowerCase(); return !query || values.some(value => String(value || '').toLowerCase().includes(query)); }, setViewMode(mode) { this.viewMode = mode; localStorage.setItem('coolify-s3-storages-view', mode); } }"> - @include('livewire.shared.list-search-controls', ['placeholder' => 'Search S3 storages', 'singular' => 'storage', 'plural' => 'storages']) + @include('livewire.shared.list-search-controls', ['placeholder' => 'Search S3 storages'])
    @foreach ($s3 as $storage) 'Danger Zone', 'route' => 'storage.danger', 'active' => $currentRoute === 'storage.danger', - 'icon' => 'shield-alert', + 'icon' => 'danger', ], ]; @endphp @@ -36,12 +36,14 @@ @if ($showSettingsSidebar)
    - +
    @if ($currentRoute === 'storage.show') diff --git a/resources/views/livewire/tags/canvas.blade.php b/resources/views/livewire/tags/canvas.blade.php index 3a01be7f6f..246addc1e6 100644 --- a/resources/views/livewire/tags/canvas.blade.php +++ b/resources/views/livewire/tags/canvas.blade.php @@ -2,7 +2,7 @@ Tags > Canvas | Coolify -
    {{-- The canvas moves this group into its toolbar (page actions last, at the right end), diff --git a/resources/views/source/all.blade.php b/resources/views/source/all.blade.php index fc93d4a72c..2a3ebe330b 100644 --- a/resources/views/source/all.blade.php +++ b/resources/views/source/all.blade.php @@ -71,7 +71,7 @@ matches(values) { const query = this.search.trim().toLowerCase(); return !query || values.some(value => String(value || '').toLowerCase().includes(query)); }, setViewMode(mode) { this.viewMode = mode; localStorage.setItem('coolify-sources-view', mode); } }"> - @include('livewire.shared.list-search-controls', ['placeholder' => 'Search sources', 'singular' => 'source', 'plural' => 'sources']) + @include('livewire.shared.list-search-controls', ['placeholder' => 'Search sources'])
    @foreach ($sources as $source) @if ($source->getMorphClass() === 'App\Models\GithubApp') diff --git a/routes/web.php b/routes/web.php index 4dde2c922f..a484892564 100644 --- a/routes/web.php +++ b/routes/web.php @@ -26,6 +26,7 @@ use App\Livewire\Admin\Index as AdminIndex; use App\Livewire\Analytics; use App\Livewire\Boarding\Index as BoardingIndex; use App\Livewire\Dashboard; +use App\Livewire\Dashboard\Canvas as DashboardCanvas; use App\Livewire\DeployCanvas; use App\Livewire\Destination\Index as DestinationIndex; use App\Livewire\Destination\Resources as DestinationResources; @@ -192,6 +193,7 @@ Route::middleware(['auth', 'verified'])->group(function () { }); Route::get('/', Dashboard::class)->name('dashboard'); + Route::get('/dashboard/canvas', DashboardCanvas::class)->name('dashboard.canvas'); Route::get('/analytics', Analytics::class)->name('analytics'); Route::get('/metrics', FleetMetrics::class)->name('metrics'); Route::get('/canvas', DeployCanvas::class)->name('deploy-canvas'); @@ -220,8 +222,10 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::delete('/shared-variables/{scope}/{uuid}/canvas/links/{link_id}', [SharedVariableCanvasController::class, 'destroyLink'])->whereIn('scope', ['project', 'environment', 'server'])->middleware(['api.ability:write']); Route::get('/servers', [ServersController::class, 'servers'])->middleware(['api.ability:read']); Route::get('/servers/canvas', [ServerCanvasController::class, 'index'])->middleware(['api.ability:read']); + Route::get('/dashboard/canvas', [ServerCanvasController::class, 'dashboard'])->middleware(['api.ability:read']); Route::get('/servers/{uuid}/canvas', [ServerCanvasController::class, 'show'])->middleware(['api.ability:read']); Route::get('/servers/{uuid}/canvas/containers', [ServerCanvasController::class, 'containers'])->middleware(['api.ability:read']); + Route::get('/servers/{uuid}/canvas/usage', [ServerCanvasController::class, 'usage'])->middleware(['api.ability:read']); Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['api.ability:read']); Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['api.ability:read']); @@ -387,8 +391,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/persistent-storage', ApplicationConfiguration::class)->name('project.application.persistent-storage'); Route::get('/backups', ApplicationBackupIndex::class)->name('project.application.backup.index'); Route::get('/backups/{backup_uuid}', ApplicationBackupShow::class)->name('project.application.backup.show'); - Route::get('/backups/{backup_uuid}/s3', ApplicationBackupShow::class)->name('project.application.backup.s3'); - Route::get('/backups/{backup_uuid}/retention', ApplicationBackupShow::class)->name('project.application.backup.retention'); + // S3 and retention settings moved into the general page; keep old links working. + Route::redirect('/backups/{backup_uuid}/s3', '/project/{project_uuid}/environment/{environment_uuid}/application/{application_uuid}/backups/{backup_uuid}'); + Route::redirect('/backups/{backup_uuid}/retention', '/project/{project_uuid}/environment/{environment_uuid}/application/{application_uuid}/backups/{backup_uuid}'); Route::get('/backups/{backup_uuid}/executions', ApplicationBackupShow::class)->name('project.application.backup.executions'); Route::get('/backups/{backup_uuid}/danger', ApplicationBackupShow::class)->name('project.application.backup.danger'); Route::get('/source', ApplicationConfiguration::class)->name('project.application.source'); @@ -410,7 +415,8 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/logs', Logs::class)->name('project.application.logs'); Route::get('/terminal', ExecuteContainerCommand::class)->name('project.application.command')->middleware('can.access.terminal'); Route::get('/files', FileBrowser::class)->name('project.application.files')->middleware('can.access.terminal'); - Route::get('/tasks/{task_uuid}', ApplicationConfiguration::class)->name('project.application.scheduled-tasks'); + // Tasks open in a modal on the list page; keep old links (e.g. notifications) working. + Route::get('/tasks/{task_uuid}', fn () => redirect()->route('project.application.scheduled-tasks.show', get_route_parameters()))->name('project.application.scheduled-tasks'); }); Route::prefix('project/{project_uuid}/environment/{environment_uuid}/database/{database_uuid}')->group(function () { Route::get('/', DatabaseConfiguration::class)->name('project.database.configuration'); @@ -431,8 +437,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/files', FileBrowser::class)->name('project.database.files')->middleware('can.access.terminal'); Route::get('/backups', DatabaseBackupIndex::class)->name('project.database.backup.index'); Route::get('/backups/{backup_uuid}', DatabaseBackupExecution::class)->name('project.database.backup.execution'); - Route::get('/backups/{backup_uuid}/s3', DatabaseBackupExecution::class)->name('project.database.backup.s3'); - Route::get('/backups/{backup_uuid}/retention', DatabaseBackupExecution::class)->name('project.database.backup.retention'); + // S3 and retention settings moved into the general page; keep old links working. + Route::redirect('/backups/{backup_uuid}/s3', '/project/{project_uuid}/environment/{environment_uuid}/database/{database_uuid}/backups/{backup_uuid}'); + Route::redirect('/backups/{backup_uuid}/retention', '/project/{project_uuid}/environment/{environment_uuid}/database/{database_uuid}/backups/{backup_uuid}'); Route::get('/backups/{backup_uuid}/executions', DatabaseBackupExecution::class)->name('project.database.backup.executions'); Route::get('/backups/{backup_uuid}/danger', DatabaseBackupExecution::class)->name('project.database.backup.danger'); }); @@ -447,8 +454,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/import-backup/{stack_service_uuid}', ServiceImportBackup::class)->name('project.service.import-backup.database')->middleware('can.update.resource'); Route::get('/storage-backups', Index::class)->name('project.service.volume-backups.index'); Route::get('/storage-backups/{backup_uuid}', Show::class)->name('project.service.volume-backups.show'); - Route::get('/storage-backups/{backup_uuid}/s3', Show::class)->name('project.service.volume-backups.s3'); - Route::get('/storage-backups/{backup_uuid}/retention', Show::class)->name('project.service.volume-backups.retention'); + // S3 and retention settings moved into the general page; keep old links working. + Route::redirect('/storage-backups/{backup_uuid}/s3', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/storage-backups/{backup_uuid}'); + Route::redirect('/storage-backups/{backup_uuid}/retention', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/storage-backups/{backup_uuid}'); Route::get('/storage-backups/{backup_uuid}/executions', Show::class)->name('project.service.volume-backups.executions'); Route::get('/storage-backups/{backup_uuid}/danger', Show::class)->name('project.service.volume-backups.danger'); Route::get('/scheduled-tasks', ServiceConfiguration::class)->name('project.service.scheduled-tasks.show'); @@ -460,14 +468,15 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/files', FileBrowser::class)->name('project.service.files')->middleware('can.access.terminal'); Route::get('/{stack_service_uuid}/backups', ServiceDatabaseBackups::class)->name('project.service.database.backups'); Route::get('/{stack_service_uuid}/backups/{backup_uuid}', ServiceDatabaseBackups::class)->name('project.service.database.backup.show'); - Route::get('/{stack_service_uuid}/backups/{backup_uuid}/s3', ServiceDatabaseBackups::class)->name('project.service.database.backup.s3'); - Route::get('/{stack_service_uuid}/backups/{backup_uuid}/retention', ServiceDatabaseBackups::class)->name('project.service.database.backup.retention'); + Route::redirect('/{stack_service_uuid}/backups/{backup_uuid}/s3', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/{stack_service_uuid}/backups/{backup_uuid}'); + Route::redirect('/{stack_service_uuid}/backups/{backup_uuid}/retention', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/{stack_service_uuid}/backups/{backup_uuid}'); Route::get('/{stack_service_uuid}/backups/{backup_uuid}/executions', ServiceDatabaseBackups::class)->name('project.service.database.backup.executions'); Route::get('/{stack_service_uuid}/backups/{backup_uuid}/danger', ServiceDatabaseBackups::class)->name('project.service.database.backup.danger'); Route::get('/{stack_service_uuid}/import', ServiceImportBackup::class)->name('project.service.database.import')->middleware('can.update.resource'); Route::get('/{stack_service_uuid}/advanced', ServiceIndex::class)->name('project.service.index.advanced'); Route::get('/{stack_service_uuid}', ServiceIndex::class)->name('project.service.index'); - Route::get('/tasks/{task_uuid}', ServiceConfiguration::class)->name('project.service.scheduled-tasks'); + // Tasks open in a modal on the list page; keep old links (e.g. notifications) working. + Route::get('/tasks/{task_uuid}', fn () => redirect()->route('project.service.scheduled-tasks.show', get_route_parameters()))->name('project.service.scheduled-tasks'); }); Route::get('/servers', ServerIndex::class)->name('server.index'); @@ -570,15 +579,29 @@ Route::middleware(['auth'])->group(function () { } } $filename = data_get($execution, 'filename'); - if ($execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class) { - $server = $execution->scheduledDatabaseBackup->database->service->destination->server; - } else { - $server = $execution->scheduledDatabaseBackup->database->destination->server; + if (! $execution->local_storage_deleted && filled($filename)) { + if ($execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class) { + $server = $execution->scheduledDatabaseBackup->database->service->destination->server; + } else { + $server = $execution->scheduledDatabaseBackup->database->destination->server; + } + + try { + return streamBackupFromServer($server, $filename, 'application/octet-stream'); + } catch (FileNotFoundException) { + // Fall back to an S3 copy. + } } - return streamBackupFromServer($server, $filename, 'application/octet-stream'); - } catch (FileNotFoundException) { - if (isset($execution) && $execution->scheduledDatabaseBackup->disable_local_backup === true && $execution->scheduledDatabaseBackup->save_s3 === true) { + // Only storages of the team that owns the database can serve the copy. + $s3DownloadUrl = is_null($execution_team_id) + ? null + : $execution->s3DownloadUrl($execution_team_id, $execution->scheduledDatabaseBackup->s3_storage_id); + if ($s3DownloadUrl) { + return redirect()->away($s3DownloadUrl); + } + + if ($execution->scheduledDatabaseBackup->disable_local_backup && $execution->scheduledDatabaseBackup->save_s3) { return response()->json(['message' => 'Backup not available locally, but available on S3.'], 404); } @@ -605,17 +628,29 @@ Route::middleware(['auth'])->group(function () { if ($team->id !== 0 && $team->id !== $execution->scheduledVolumeBackup->team_id) { return response()->json(['message' => 'Permission denied.'], 403); } - if ($execution->local_storage_deleted || blank($execution->filename)) { + if (blank($execution->filename)) { return response()->json(['message' => 'Backup not found locally on the server.'], 404); } - $server = $execution->scheduledVolumeBackup->server(); - if (! $server) { + $server = $execution->local_storage_deleted ? null : $execution->scheduledVolumeBackup->server(); + if ($server) { + try { + return streamBackupFromServer($server, $execution->filename, 'application/gzip'); + } catch (FileNotFoundException) { + // Fall back to an S3 copy. + } + } + + // Only storages of the team that owns the backup can serve the copy. + $s3DownloadUrl = $execution->s3DownloadUrl($execution->scheduledVolumeBackup->team_id, $execution->s3_storage_id); + if ($s3DownloadUrl) { + return redirect()->away($s3DownloadUrl); + } + + if (! $execution->local_storage_deleted && ! $server) { return response()->json(['message' => 'Server not found.'], 404); } - return streamBackupFromServer($server, $execution->filename, 'application/gzip'); - } catch (FileNotFoundException) { return response()->json(['message' => 'Backup not found locally on the server.'], 404); } catch (Throwable) { return response()->json(['message' => 'Failed to download backup.'], 500); diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php index 3479e8db11..93d38c78e5 100644 --- a/tests/Feature/BackupEditValidationTest.php +++ b/tests/Feature/BackupEditValidationTest.php @@ -86,7 +86,6 @@ it('enables and disables S3 backups from the S3 title action', function () { $component = Livewire::test(BackupEdit::class, [ 'backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, - 'section' => 's3', ]) ->assertSee('Enable S3') ->call('toggleS3') @@ -117,7 +116,6 @@ it('shows and saves S3 retention while S3 backups are disabled', function () { Livewire::test(BackupEdit::class, [ 'backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, - 'section' => 'retention', ]) ->assertSee('S3 backups') ->set('databaseBackupRetentionAmountS3', 12) @@ -147,34 +145,18 @@ it('splits standalone database backup settings and executions across dedicated u $this->get($generalUrl) ->assertOk() ->assertSee('General') - ->assertSee('S3 storage') - ->assertSee('Retention') ->assertSee('Executions') ->assertSee('Danger Zone') ->assertSee('Frequency') - ->assertDontSee('Enable S3') - ->assertDontSee('Disable S3') - ->assertDontSee('Backups to keep') - ->assertDontSee('Clean failed backups') - ->assertDontSee('Delete schedule'); - - $this->get($generalUrl.'/s3') - ->assertOk() ->assertSeeText('No validated S3 storage') - ->assertDontSee('Local copy') - ->assertDontSee('Enable S3') - ->assertDontSee('Disable S3') - ->assertDontSee('Backups to keep') - ->assertDontSee('Frequency') - ->assertDontSee('Clean failed backups'); - - $this->get($generalUrl.'/retention') - ->assertOk() ->assertSee('Local backups') ->assertSee('S3 backups') ->assertSee('Backups to keep') - ->assertDontSee('Frequency') - ->assertDontSee('Clean failed backups'); + ->assertDontSee('Clean failed backups') + ->assertDontSee('Delete schedule'); + + $this->get($generalUrl.'/s3')->assertRedirect($generalUrl); + $this->get($generalUrl.'/retention')->assertRedirect($generalUrl); $this->get($generalUrl.'/executions') ->assertOk() @@ -478,22 +460,21 @@ it('shows available S3 storages even when S3 backup is disabled', function () { 's3_storage_id' => null, ]); - Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) ->assertSee('First S3') ->assertSee('Second S3'); }); -it('shows only an empty S3 state when no storages are available', function () { +it('shows an empty S3 state when no storages are available', function () { $backup = createBackupForEditValidationTest($this->team, [ 'save_s3' => false, 's3_storage_id' => null, ]); - Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) ->assertSeeText('No validated S3 storage') ->assertSeeHtml('href="'.route('storage.index').'"') ->assertSeeText('Open S3 storage') - ->assertDontSee('Save') ->assertDontSee('Enable S3') ->assertDontSee('Disable S3') ->assertDontSee('S3 Storage') @@ -510,7 +491,6 @@ it('allows S3 backups to be disabled when no usable storage remains', function ( $component = Livewire::test(BackupEdit::class, [ 'backup' => $backup->fresh(), 'availableS3Storages' => collect(), - 'section' => 's3', ]) ->assertSet('saveS3', true) ->assertSeeText('No validated S3 storage') @@ -532,7 +512,7 @@ it('shows when S3 backups are currently disabled', function () { 's3_storage_id' => null, ]); - Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) // Disabled S3 backups are indicated by the "Enable S3" title action ->assertSee('S3 storage') ->assertSee('Enable S3') @@ -617,3 +597,84 @@ it('disables Back up now after refresh when the database stops', function () { expect($component->html())->toMatch('/]*wire:click="backupNow"/s'); }); + +it('shows the backup actions in the header bar on every database backup page', function () { + config(['cache.default' => 'array', 'app.maintenance.driver' => 'file']); + $backup = createBackupForEditValidationTest($this->team); + $database = $backup->database; + $database->update(['status' => 'running:healthy']); + $generalUrl = route('project.database.backup.execution', [ + 'project_uuid' => $database->project()->uuid, + 'environment_uuid' => $database->environment->uuid, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $backup->uuid, + ]); + + foreach (['', '/executions', '/danger'] as $suffix) { + expect($this->get($generalUrl.$suffix)->assertOk()->getContent()) + ->toMatch('/x-teleport="#resource-action-hud-slot".*?wire:click="toggleEnabled".*?Disable backup.*?wire:click="backupNow"/s'); + } +}); + +it('hides the header Back up now button from team members', function () { + config(['cache.default' => 'array', 'app.maintenance.driver' => 'file']); + $backup = createBackupForEditValidationTest($this->team); + $database = $backup->database; + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + + $this->get(route('project.database.backup.executions', [ + 'project_uuid' => $database->project()->uuid, + 'environment_uuid' => $database->environment->uuid, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $backup->uuid, + ])) + ->assertOk() + ->assertDontSee('wire:click="backupNow"', false); +}); + +it('denies Back up now for team members', function () { + Queue::fake(); + $backup = createBackupForEditValidationTest($this->team); + $backup->database->update(['status' => 'running:healthy']); + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + + Livewire::test(BackupEdit::class, [ + 'backup' => $backup->fresh(), + 'availableS3Storages' => $this->team->s3s, + ]) + ->call('backupNow') + ->assertNoRedirect(); + + Queue::assertNotPushed(DatabaseBackupJob::class); +}); + +it('syncs the backup form when the header toggles the schedule', function () { + $backup = createBackupForEditValidationTest($this->team, ['enabled' => true]); + + $component = Livewire::test(BackupEdit::class, [ + 'backup' => $backup->fresh(), + 'availableS3Storages' => $this->team->s3s, + ])->assertSet('backupEnabled', true); + + $backup->update(['enabled' => false]); + + $component->dispatch('backupEnabledChanged') + ->assertSet('backupEnabled', false) + ->call('submit'); + + expect((bool) $backup->fresh()->enabled)->toBeFalse(); +}); + +it('toggles the schedule from the form and notifies the page header', function () { + $backup = createBackupForEditValidationTest($this->team, ['enabled' => true]); + + Livewire::test(BackupEdit::class, [ + 'backup' => $backup->fresh(), + 'availableS3Storages' => $this->team->s3s, + ]) + ->call('toggleEnabled') + ->assertSet('backupEnabled', false) + ->assertDispatched('backupEnabledChanged'); + + expect((bool) $backup->fresh()->enabled)->toBeFalse(); +}); diff --git a/tests/Feature/BackupS3DestinationsFormTest.php b/tests/Feature/BackupS3DestinationsFormTest.php index 211360408a..2e025fda21 100644 --- a/tests/Feature/BackupS3DestinationsFormTest.php +++ b/tests/Feature/BackupS3DestinationsFormTest.php @@ -107,7 +107,7 @@ beforeEach(function () { it('saves two S3 destinations for a database backup schedule', function () { $backup = createDatabaseBackupForDestinationsFormTest($this->database); - Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) ->assertSee('First S3') ->assertSee('Second S3') ->assertSet('s3StorageIds', [$this->firstStorage->id]) @@ -304,12 +304,12 @@ it('keeps a selected destination that is unusable for a while when other setting $volumeBackup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); $this->secondStorage->update(['is_usable' => false]); - Livewire::test(BackupEdit::class, ['backup' => $databaseBackup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $databaseBackup->fresh(), 'availableS3Storages' => $this->team->s3s]) ->assertSee('Second S3 (unavailable)') ->set('timeout', 7200) ->call('submit') ->assertHasNoErrors(); - Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application, 'section' => 's3']) + Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application]) ->assertSee('Second S3 (unavailable)') ->set('stopDuringBackup', true) ->call('save') diff --git a/tests/Feature/BackupS3DownloadFallbackTest.php b/tests/Feature/BackupS3DownloadFallbackTest.php new file mode 100644 index 0000000000..5eccab54b6 --- /dev/null +++ b/tests/Feature/BackupS3DownloadFallbackTest.php @@ -0,0 +1,183 @@ + 'file']); + Server::flushIdentityMap(); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->otherTeam = Team::factory()->create(); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create([ + 'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id, + ]); + $database = StandalonePostgresql::create([ + 'name' => 's3-download-pg', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + + $this->primaryS3 = createS3ForDownloadFallbackTest($this->team, 'primary'); + $this->replicaS3 = createS3ForDownloadFallbackTest($this->team, 'replica'); + + $this->databaseBackup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + 'save_s3' => true, + 'disable_local_backup' => true, + 's3_storage_id' => $this->primaryS3->id, + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + ]); + $this->databaseExecution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $this->databaseBackup->id, + 'status' => 'success', + 'database_name' => 'postgres', + 'filename' => '/data/coolify/backups/databases/team/pg/pg-dump-postgres-1.dmp', + 'local_storage_deleted' => true, + ]); + + $this->volumeBackup = $volume->scheduledBackups()->create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + ]); + $this->volumeExecution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $this->volumeBackup->id, + 's3_storage_id' => $this->primaryS3->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/team/app/volume-app-data-1.tar.gz', + 'local_storage_deleted' => true, + ]); +}); + +function createS3ForDownloadFallbackTest(Team $team, string $name, bool $isUsable = true): S3Storage +{ + return S3Storage::create([ + 'name' => $name, + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => "{$name}-{$team->id}-bucket", + 'endpoint' => 'https://1.1.1.1', + 'team_id' => $team->id, + 'is_usable' => $isUsable, + ]); +} + +function signInForDownloadFallbackTest($testCase, Team $team, string $role = 'owner'): void +{ + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => $role]); + $testCase->actingAs($user); + session(['currentTeam' => $team]); +} + +/** + * @return array{0: string, 1: ScheduledDatabaseBackupExecution|ScheduledVolumeBackupExecution} + */ +function downloadFallbackTarget($testCase, string $type): array +{ + return $type === 'database' + ? ['download.backup', $testCase->databaseExecution] + : ['download.volume-backup', $testCase->volumeExecution]; +} + +function assertPresignedBackupRedirect($response, S3Storage $storage, string $filename): void +{ + $location = $response->assertRedirect()->headers->get('Location'); + $url = parse_url($location); + parse_str($url['query'], $query); + + expect("{$url['scheme']}://{$url['host']}{$url['path']}")->toBe("https://1.1.1.1/{$storage->bucket}".$filename) + ->and($query)->toHaveKeys(['X-Amz-Signature', 'X-Amz-Expires']) + ->and((int) $query['X-Amz-Expires'])->toBeLessThanOrEqual(15 * 60) + ->and($query['response-content-disposition'])->toContain('attachment; filename="'.basename($filename).'"'); +} + +it('redirects to a presigned URL of the primary S3 copy when no local copy exists', function (string $type) { + [$route, $execution] = downloadFallbackTarget($this, $type); + $execution->s3Replicas()->create(['s3_storage_id' => $this->replicaS3->id, 's3_uploaded' => true]); + $execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => true]); + signInForDownloadFallbackTest($this, $this->team); + + assertPresignedBackupRedirect($this->get(route($route, $execution->id)), $this->primaryS3, $execution->filename); +})->with(['database', 'volume']); + +it('uses a replica when the primary S3 copy is deleted or its storage is unusable', function (string $type, bool $primaryDeleted) { + [$route, $execution] = downloadFallbackTarget($this, $type); + if (! $primaryDeleted) { + $this->primaryS3->update(['is_usable' => false]); + } + $execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => true, 's3_storage_deleted' => $primaryDeleted]); + $execution->s3Replicas()->create(['s3_storage_id' => $this->replicaS3->id, 's3_uploaded' => true]); + signInForDownloadFallbackTest($this, $this->team); + + assertPresignedBackupRedirect($this->get(route($route, $execution->id)), $this->replicaS3, $execution->filename); +})->with(['database', 'volume'])->with(['primary deleted' => true, 'primary unusable' => false]); + +it('keeps the not found message when no usable S3 copy exists', function (string $type, string $message) { + [$route, $execution] = downloadFallbackTarget($this, $type); + $execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => false]); + $execution->s3Replicas()->create(['s3_storage_id' => $this->replicaS3->id, 's3_uploaded' => true, 's3_storage_deleted' => true]); + signInForDownloadFallbackTest($this, $this->team); + + $this->get(route($route, $execution->id))->assertNotFound()->assertJson(['message' => $message]); +})->with([ + 'database' => ['database', 'Backup not available locally, but available on S3.'], + 'volume' => ['volume', 'Backup not found locally on the server.'], +]); + +it('never uses an S3 storage of another team', function (string $type) { + [$route, $execution] = downloadFallbackTarget($this, $type); + $foreignS3 = createS3ForDownloadFallbackTest($this->otherTeam, 'foreign'); + $execution->s3Replicas()->create(['s3_storage_id' => $foreignS3->id, 's3_uploaded' => true]); + signInForDownloadFallbackTest($this, $this->team); + + $this->get(route($route, $execution->id))->assertNotFound(); +})->with(['database', 'volume']); + +it('forbids members and other teams from getting a presigned URL', function (string $type, string $user) { + [$route, $execution] = downloadFallbackTarget($this, $type); + $execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => true]); + $user === 'member' + ? signInForDownloadFallbackTest($this, $this->team, 'member') + : signInForDownloadFallbackTest($this, $this->otherTeam); + + $this->get(route($route, $execution->id))->assertForbidden(); +})->with(['database', 'volume'])->with(['member', 'other team owner']); diff --git a/tests/Feature/BackupS3DownloadTest.php b/tests/Feature/BackupS3DownloadTest.php new file mode 100644 index 0000000000..a04d90950e --- /dev/null +++ b/tests/Feature/BackupS3DownloadTest.php @@ -0,0 +1,287 @@ + 'file', 'constants.ssh.mux_enabled' => false]); + Storage::fake('ssh-keys'); + Server::flushIdentityMap(); + Once::flush(); + InstanceSettings::forceCreate(['id' => 0]); + Team::factory()->create(['id' => 0]); + $this->downloadStorage = S3Storage::create([ + 'name' => 'Downloads', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'downloads', + 'endpoint' => 'https://s3.example.com', + 'is_usable' => true, + 'team_id' => 0, + ]); + InstanceSettings::find(0)->update(['file_download_s3_storage_id' => $this->downloadStorage->id]); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->otherTeam = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]); + $destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]); + $this->database = StandalonePostgresql::create([ + 'name' => 'orders-db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', + 'image' => 'postgres:17', 'environment_id' => $environment->id, + 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), + ]); + $this->backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + ]); + $this->execution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $this->backup->id, + 'status' => 'success', + 'filename' => BACKUP_S3_DOWNLOAD_FILE, + ]); + $this->service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $environment->id, + ]); + + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'admin']); + $this->user->teams()->attach($this->otherTeam, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + Bus::fake([PrepareFileDownloadJob::class]); +}); + +function fakeBackupFileSize(int|string $size): void +{ + Process::fake(['*' => Process::result(output: (string) $size)]); +} + +it('uploads a database backup through S3 for an admin of its team after a team switch', function () { + fakeBackupFileSize(1024); + $component = Livewire::test(BackupExecutions::class, ['backup' => $this->backup]); + session(['currentTeam' => $this->otherTeam]); + + $component->call('download_file', $this->execution->id) + ->assertDispatched('info') + ->assertNotDispatched('open-backup-download') + ->assertCount('pendingDownloads', 1); + + Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->container === null + && $job->path === BACKUP_S3_DOWNLOAD_FILE + && $job->serverId === $this->server->id + && $job->downloadName === 'pg-dump-orders-1.dmp' + && $job->s3StorageId === $this->downloadStorage->id + && $job->userId === $this->user->id); +}); + +it('streams through the download route when no download storage is set', function () { + InstanceSettings::find(0)->update(['file_download_s3_storage_id' => null]); + Once::flush(); + Process::fake(); + + Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $this->execution->id) + ->assertDispatched('open-backup-download', url: route('download.backup', $this->execution->id)); + + Process::assertNothingRan(); + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('streams through the download route when the backup is larger than one S3 upload', function () { + fakeBackupFileSize(FileDownloadStorageService::MAX_SIZE + 1); + + Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $this->execution->id) + ->assertDispatched('open-backup-download', url: route('download.backup', $this->execution->id)); + + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('lets the download route answer when the backup file is not on the server', function () { + fakeBackupFileSize(''); + + Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $this->execution->id) + ->assertDispatched('open-backup-download', url: route('download.backup', $this->execution->id)); + + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('refuses team members', function () { + Process::fake(); + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + $this->user->unsetRelation('teams'); + $component = Livewire::test(BackupExecutions::class, ['backup' => $this->backup]); + + $component->call('download_file', $this->execution->id) + ->assertNotDispatched('open-backup-download'); + + Process::assertNothingRan(); + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('does not download an execution of another backup', function () { + Process::fake(); + $foreignBackup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->otherTeam->id, + 'frequency' => 'daily', + 'database_id' => $this->database->id + 1000, + 'database_type' => $this->database->getMorphClass(), + ]); + $foreignExecution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $foreignBackup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/databases/other/secret.dmp', + ]); + + Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $foreignExecution->id) + ->assertDispatched('error', 'Backup execution not found.') + ->assertNotDispatched('open-backup-download'); + + Process::assertNothingRan(); + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('uploads a volume backup through S3 and refuses members', function () { + fakeBackupFileSize(2048); + $application = Application::factory()->create([ + 'environment_id' => $this->database->environment_id, + 'destination_id' => $this->database->destination_id, + 'destination_type' => $this->database->destination_type, + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + $backup = $volume->scheduledBackups()->create(['team_id' => $this->team->id, 'frequency' => 'daily']); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/app/archive.tar.gz', + ]); + $component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]); + session(['currentTeam' => $this->otherTeam]); + + $component->call('downloadBackup', $execution->id)->assertCount('pendingDownloads', 1); + Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->container === null + && $job->path === '/data/coolify/backups/volumes/app/archive.tar.gz' + && $job->serverId === $this->server->id); + + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + $this->user->unsetRelation('teams'); + $component->call('downloadBackup', $execution->id)->assertForbidden(); + Bus::assertDispatchedTimes(PrepareFileDownloadJob::class, 1); +}); + +it('uploads a service database backup through S3', function () { + fakeBackupFileSize(1024); + $serviceDatabase = ServiceDatabase::create([ + 'service_id' => $this->service->id, + 'name' => 'service-db', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + 'database_id' => $serviceDatabase->id, + 'database_type' => $serviceDatabase->getMorphClass(), + ]); + $execution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/service/db.dmp', + ]); + + Livewire::test(ServiceBackupExecutions::class, ['service' => $this->service]) + ->call('downloadExecution', $execution->uuid) + ->assertCount('pendingDownloads', 1); + + Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->container === null + && $job->path === '/data/coolify/backups/service/db.dmp'); +}); + +it('opens a finished link only for the user who asked for it', function () { + fakeBackupFileSize(1024); + $component = Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $this->execution->id); + $downloadId = $component->get('pendingDownloads')[0]; + + Cache::put(PrepareFileDownloadJob::cacheKey($downloadId), ['user_id' => $this->user->id + 1, 'status' => 'ready', 'url' => 'https://s3.example.com/other']); + $component->call('checkDownloads')->assertNotDispatched('open-backup-download')->assertCount('pendingDownloads', 1); + + Cache::put(PrepareFileDownloadJob::cacheKey($downloadId), ['user_id' => $this->user->id, 'status' => 'ready', 'url' => 'https://s3.example.com/get']); + $component->call('checkDownloads') + ->assertDispatched('open-backup-download', url: 'https://s3.example.com/get') + ->assertCount('pendingDownloads', 0); +}); + +it('uploads the backup file from the server host with curl', function () { + Bus::fake([DeleteFileDownloadJob::class]); + Process::fake(['*' => Process::result(output: '')]); + $downloads = Mockery::mock(FileDownloadStorageService::class); + $downloads->shouldReceive('uploadUrl')->andReturn(['url' => 'https://s3.example.com/put?X-Amz-Signature=abc&x=1', 'headers' => ['Content-Disposition' => 'attachment']]); + $downloads->shouldReceive('downloadUrl')->andReturn('https://s3.example.com/get'); + + (new PrepareFileDownloadJob('dl-host', $this->user->id, $this->server->id, null, BACKUP_S3_DOWNLOAD_FILE, false, 'pg.dmp', $this->downloadStorage->id))->handle($downloads); + + expect(Cache::get(PrepareFileDownloadJob::cacheKey('dl-host')))->toMatchArray(['status' => 'ready', 'url' => 'https://s3.example.com/get']); + Process::assertRan(fn ($process) => str_contains($process->command, 'curl -fsS') + && str_contains($process->command, BACKUP_S3_DOWNLOAD_FILE) + && str_contains($process->command, 'X-Amz-Signature=abc&x=1') + && ! str_contains($process->command, 'docker exec')); +}); + +it('builds host file commands that the non-root sudo parser keeps whole', function () { + $nonRoot = Server::factory()->make(['user' => 'deploy']); + $upload = FileDownloadStorageService::buildHostFileUploadCommand("/data/it's.dmp", 'https://s3.example.com/put?a=1&b=2', ['Content-Disposition' => 'attachment']); + $size = FileDownloadStorageService::buildHostFileSizeCommand("/data/it's.dmp"); + + expect(parseCommandsByLineForSudo(collect([$upload, $size]), $nonRoot))->toBe(['sudo '.$upload, 'sudo '.$size]) + ->and(fn () => FileDownloadStorageService::buildHostFileSizeCommand('/data/a;rm -rf /'))->toThrow(Exception::class); +}); diff --git a/tests/Feature/BackupS3StorageListResourceTeamTest.php b/tests/Feature/BackupS3StorageListResourceTeamTest.php index 90f87493b4..d4b61b16b4 100644 --- a/tests/Feature/BackupS3StorageListResourceTeamTest.php +++ b/tests/Feature/BackupS3StorageListResourceTeamTest.php @@ -112,7 +112,7 @@ test('database backup page offers only usable S3 storages of the database team', 'database_id' => $this->postgres->id, 'database_type' => $this->postgres->getMorphClass(), ]); - $url = route('project.database.backup.s3', [ + $url = route('project.database.backup.execution', [ 'project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid, 'database_uuid' => $this->postgres->uuid, diff --git a/tests/Feature/DashboardCanvasTest.php b/tests/Feature/DashboardCanvasTest.php new file mode 100644 index 0000000000..9204b3e77a --- /dev/null +++ b/tests/Feature/DashboardCanvasTest.php @@ -0,0 +1,132 @@ + 'array', + 'session.driver' => 'array', + 'queue.default' => 'sync', + 'app.maintenance.driver' => 'file', + ]); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true])); + Server::flushIdentityMap(); + + $this->team = Team::factory()->create(['show_boarding' => false]); + $this->admin = User::factory()->create(); + $this->team->members()->attach($this->admin->id, ['role' => 'admin']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'web-server']); + $this->environment = Environment::factory()->create([ + 'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id, + ]); +}); + +function dashboardCanvasApp(Environment $environment, Server $server, string $name): Application +{ + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + + return Application::factory()->create([ + 'name' => $name, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +function dashboardCanvasDeployment(Application $application, Server $server, ApplicationDeploymentStatus $status): ApplicationDeploymentQueue +{ + return ApplicationDeploymentQueue::query()->forceCreate([ + 'application_id' => (string) $application->id, + 'application_name' => $application->name, + 'deployment_uuid' => (string) str()->uuid(), + 'deployment_url' => '/deployment/'.$status->value, + 'server_id' => $server->id, + 'server_name' => $server->name, + 'status' => $status->value, + ]); +} + +function dashboardCanvasSession(object $test, User $user, Team $team): object +{ + return $test->actingAs($user)->withSession(['currentTeam' => $team]) + ->withHeader('X-Canvas-Team', (string) $team->id); +} + +test('the dashboard canvas marks applications with an active deployment and lists recent deployments', function () { + $deploying = dashboardCanvasApp($this->environment, $this->server, 'storefront'); + $queued = dashboardCanvasApp($this->environment, $this->server, 'orders-api'); + $idle = dashboardCanvasApp($this->environment, $this->server, 'docs'); + dashboardCanvasDeployment($deploying, $this->server, ApplicationDeploymentStatus::IN_PROGRESS); + dashboardCanvasDeployment($queued, $this->server, ApplicationDeploymentStatus::QUEUED); + dashboardCanvasDeployment($idle, $this->server, ApplicationDeploymentStatus::FINISHED); + + $response = dashboardCanvasSession($this, $this->admin, $this->team) + ->getJson('/canvas/api/dashboard/canvas') + ->assertOk() + ->assertJsonPath('title', 'Dashboard') + ->assertJsonPath('zones.0.id', $this->server->uuid); + $nodes = collect($response->json('nodes'))->keyBy('id'); + + expect($nodes[$deploying->uuid]['deployment'])->toBe(['status' => 'in_progress', 'link' => '/deployment/in_progress', 'pull_request_id' => null]) + ->and($nodes[$queued->uuid]['deployment']['status'])->toBe('queued') + ->and($nodes[$idle->uuid])->not->toHaveKey('deployment') + // The deployment panel lists running and queued deployments first, then the finished ones. + ->and(collect($response->json('deployments'))->map(fn (array $row) => [$row['application'], $row['status']])->all())->toBe([ + [$deploying->uuid, 'in_progress'], + [$queued->uuid, 'queued'], + [$idle->uuid, 'finished'], + ]); +}); + +test('the dashboard canvas never shows another team servers, resources, or deployments', function () { + $otherTeam = Team::factory()->create(['show_boarding' => false]); + $outsider = User::factory()->create(); + $otherTeam->members()->attach($outsider->id, ['role' => 'owner']); + $this->team->members()->attach($outsider->id, ['role' => 'member']); + $app = dashboardCanvasApp($this->environment, $this->server, 'storefront'); + dashboardCanvasDeployment($app, $this->server, ApplicationDeploymentStatus::IN_PROGRESS); + + $response = dashboardCanvasSession($this, $outsider, $otherTeam) + ->getJson('/canvas/api/dashboard/canvas') + ->assertOk(); + + expect($response->json('zones'))->toBe([]) + ->and($response->json('nodes'))->toBe([]) + ->and($response->json('deployments'))->toBe([]); +}); + +test('the dashboard opens the canvas again when the user left it there', function () { + $cookie = "coolify_{$this->admin->id}_dashboard_view"; + + dashboardCanvasSession($this, $this->admin, $this->team) + ->withoutVite() + ->get(route('dashboard.canvas')) + ->assertOk() + ->assertSee('data-endpoint="/dashboard/canvas"', false) + ->assertCookie($cookie, 'canvas'); + + dashboardCanvasSession($this, $this->admin, $this->team) + ->withCookie($cookie, 'canvas') + ->get(route('dashboard')) + ->assertRedirect(route('dashboard.canvas')); + + // The list link on the canvas opens the list and makes it the remembered view. + dashboardCanvasSession($this, $this->admin, $this->team) + ->withCookie($cookie, 'canvas') + ->get(route('dashboard', ['view' => 'list'])) + ->assertOk() + ->assertCookie($cookie, 'list'); +}); diff --git a/tests/Feature/DashboardDeploymentsTest.php b/tests/Feature/DashboardDeploymentsTest.php index 6ef6f05ab0..1fa548da4f 100644 --- a/tests/Feature/DashboardDeploymentsTest.php +++ b/tests/Feature/DashboardDeploymentsTest.php @@ -55,7 +55,6 @@ function createDashboardDeployment(array $overrides = []): ApplicationDeployment it('hides the deployments section when there is nothing to show', function () { Livewire::test(ActiveDeployments::class) - ->assertDontSee('Active and recent deployment activity') ->assertDontSee('Running or queued right now') ->assertDontSee('Latest completed deployments') ->assertDontSee('No active deployments') diff --git a/tests/Feature/DashboardViewModeTest.php b/tests/Feature/DashboardViewModeTest.php new file mode 100644 index 0000000000..a18145b0f9 --- /dev/null +++ b/tests/Feature/DashboardViewModeTest.php @@ -0,0 +1,44 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('shows the projects and servers sections as tables by default', function () { + Livewire::test(Dashboard::class) + ->assertSee("viewMode: 'table'", false) + ->assertDontSee("viewMode: 'grid'", false); +}); + +it('remembers the view of each dashboard section on its own', function () { + Livewire::test(Dashboard::class)->call('saveListView', 'dashboard_projects', 'grid'); + + expect(Cookie::queued("coolify_{$this->user->id}_dashboard_projects_view")?->getValue())->toBe('grid') + ->and(Cookie::queued("coolify_{$this->user->id}_dashboard_servers_view"))->toBeNull(); + + $this->withCookie("coolify_{$this->user->id}_dashboard_projects_view", 'grid') + ->get(route('dashboard')) + ->assertOk() + ->assertSee("viewMode: 'grid'", false) + ->assertSee("viewMode: 'table'", false); +}); diff --git a/tests/Feature/EnvironmentCanvasTest.php b/tests/Feature/EnvironmentCanvasTest.php index 8d28c0cea8..ab4e403489 100644 --- a/tests/Feature/EnvironmentCanvasTest.php +++ b/tests/Feature/EnvironmentCanvasTest.php @@ -133,9 +133,9 @@ describe('GET canvas', function () { 'kind' => 'nixpacks', 'subtitle' => 'Nixpacks · main', 'children' => [], - 'volumes' => ['web-data'], 'link' => $application->link(), 'logo' => ['src' => resource_logo_url('public-repo'), 'dark' => null, 'fallback' => null, 'padded' => false, 'favicon' => 'https://web.example.com/favicon.ico'], + 'saved' => ['x' => 10, 'y' => -20], ]) ->and($nodes[$service->uuid])->toEqual([ 'id' => $service->uuid, @@ -151,9 +151,9 @@ describe('GET canvas', function () { ['name' => 'ghost', 'type' => 'application', 'status' => 'running:healthy'], ['name' => 'mysql', 'type' => 'database', 'status' => 'running:healthy'], ], - 'volumes' => [], 'link' => $service->link(), 'logo' => null, + 'saved' => null, ]) ->and($nodes[$database->uuid])->toMatchArray([ 'type' => 'database', @@ -162,8 +162,8 @@ describe('GET canvas', function () { 'urls' => [], 'kind' => 'postgresql', 'subtitle' => 'PostgreSQL · postgres:17-alpine', - 'volumes' => ["postgres-data-{$database->uuid}"], 'logo' => ['src' => resource_logo_url('postgres'), 'dark' => null, 'fallback' => null, 'padded' => false, 'favicon' => null], + 'saved' => null, ]) ->and($response->getContent())->not->toContain('canvas-secret-password'); }); diff --git a/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php b/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php index 2cbac04a38..551e0cef0f 100644 --- a/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php +++ b/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php @@ -93,7 +93,7 @@ it('saves several S3 destinations for the instance backup and drops storages of // The session team can change in another tab after the page loads. session(['currentTeam' => ['id' => $this->otherTeam->id]]); - Livewire::test(BackupEdit::class, ['backup' => $this->backup->fresh(), 'availableS3Storages' => collect(), 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $this->backup->fresh(), 'availableS3Storages' => collect()]) ->set('saveS3', true) ->set('s3StorageIds', [$this->firstStorage->id, $this->secondStorage->id, $this->foreignStorage->id]) ->call('submit') diff --git a/tests/Feature/NavbarSubmenuTest.php b/tests/Feature/NavbarSubmenuTest.php new file mode 100644 index 0000000000..b336b46a80 --- /dev/null +++ b/tests/Feature/NavbarSubmenuTest.php @@ -0,0 +1,133 @@ + 0]); + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); +}); + +function renderNavbarAt(string $path, bool $mobile = false): string +{ + app()->instance('request', Request::create($path)); + + return (string) test()->blade($mobile ? '' : ''); +} + +function loginNavbarUser(string $role): void +{ + test()->team->members()->attach(test()->user->id, ['role' => $role]); + test()->actingAs(test()->user); + session(['currentTeam' => test()->team]); +} + +test('a submenu page opens its submenu with the current page active', function () { + loginNavbarUser('owner'); + + $html = renderNavbarAt('/metrics'); + + expect($html) + ->toContain('Back to the main menu') + ->toMatch('#aria-current="page"[^>]*>\s*]*>.*?\s*]*>Metrics#s'); +}); + +test('a page outside every submenu starts on the main menu', function () { + loginNavbarUser('owner'); + + expect(renderNavbarAt('/servers'))->not->toContain('data-active-submenu="'); +}); + +test('a submenu entry links to its first visible page', function () { + loginNavbarUser('owner'); + + expect(renderNavbarAt('/servers'))->toContain('href="'.route('analytics').'"'); +}); + +test('the registries page is hidden from members in the integrations submenu', function () { + loginNavbarUser('member'); + + expect(renderNavbarAt('/sources')) + ->toContain('Destinations') + ->not->toContain('href="'.route('registries.index').'"'); +}); + +test('the team submenu lists the team pages by role', function () { + loginNavbarUser('owner'); + + expect(renderNavbarAt('/team/members')) + ->toContain('href="'.route('team.member.index').'"') + ->toContain('href="'.route('team.audit-log').'"') + ->toContain('href="'.route('team.danger-zone').'"'); +}); + +test('members do not see the audit log or danger zone in the team submenu', function () { + loginNavbarUser('member'); + + expect(renderNavbarAt('/team')) + ->toContain('href="'.route('team.member.index').'"') + ->not->toContain('href="'.route('team.audit-log').'"') + ->not->toContain('href="'.route('team.danger-zone').'"'); +}); + +test('notifications and keys & tokens open their own submenus', function () { + loginNavbarUser('owner'); + + expect(renderNavbarAt('/notifications/discord')) + ->toContain('href="'.route('notifications.slack').'"') + ->toMatch('#aria-current="page"[^>]*>.*?discord\.svg#s'); + + expect(renderNavbarAt('/security/api-tokens')) + ->toContain('href="'.route('security.private-key.index').'"') + ->toContain('Back to the main menu'); +}); + +test('the admin link belongs to the main menu, not to submenus', function () { + config()->set('app.env', 'local'); + $root = User::factory()->create(['id' => 0]); + test()->team->members()->attach($root->id, ['role' => 'owner']); + test()->actingAs($root); + session(['currentTeam' => test()->team]); + + expect(renderNavbarAt('/team')) + ->toMatch('#]*>\s*toContain('Back to the main menu') + ->toContain('href="'.route('shared-variables.team.index').'"') + ->toMatch('#aria-current="page"[^>]*>\s*]*>.*?\s*]*>Servers#s'); +}); + +test('instance settings open their own submenu for instance admins', function () { + config()->set('app.env', 'local'); + $root = User::factory()->create(['id' => 0]); + test()->team->members()->attach($root->id, ['role' => 'owner']); + test()->actingAs($root); + session(['currentTeam' => test()->team]); + + expect(renderNavbarAt('/settings/email')) + ->toContain('Back to the main menu') + ->toContain('href="'.route('settings.oauth').'"') + ->toMatch('#aria-current="page"[^>]*>\s*]*>.*?\s*]*>Email#s'); +}); + +test('a parent entry opens its submenu in place on mobile and desktop', function () { + loginNavbarUser('owner'); + + foreach ([renderNavbarAt('/servers', mobile: true), renderNavbarAt('/servers')] as $html) { + expect($html) + ->toContain('x-show="submenu === \'Monitoring\'"') + ->toMatch('# @@ -17,4 +29,7 @@ {#if onrefresh} {/if} + {#if children} +
    {@render children()}
    + {/if}
    diff --git a/resources/js/canvas/deploy/DeployCanvas.svelte b/resources/js/canvas/deploy/DeployCanvas.svelte index 853f1ee749..89dab86f61 100644 --- a/resources/js/canvas/deploy/DeployCanvas.svelte +++ b/resources/js/canvas/deploy/DeployCanvas.svelte @@ -8,7 +8,7 @@ import PaletteItem from './PaletteItem.svelte'; import RunPanel from './RunPanel.svelte'; import CanvasControls from '../CanvasControls.svelte'; - import { closeMenu, cv, dock, els, fit, hideResult, leave, pan, pickNext, PORT_Y, resetCanvas } from './state.svelte.js'; + import { closeMenu, cv, els, fit, hideResult, leave, pan, pickNext, PORT_Y, resetCanvas } from './state.svelte.js'; // The deploy canvas: header, step palette, and the canvas with the step // cards. The state lives in state.svelte.js (init() runs before the mount). @@ -19,6 +19,10 @@ // Small screens: a notice that the canvas is not optimized for mobile. CSS // shows it below 768px; closed once, it stays closed. let noticeOpen = $state(!load('mobile-notice-dismissed', false)); + // Desktop: the header sits in the zoom controls bar (bottom left), as on the + // other canvases. Small screens: a box above the canvas. + const desktopQuery = matchMedia('(min-width: 1024px)'); + let desktop = $state(desktopQuery.matches); const stop = e => e.stopPropagation(); // A pill joined to the Deploy card: each address opens in a new tab. @@ -60,7 +64,10 @@ const reset = () => { if (els.root?.isConnected) resetCanvas(); }; addEventListener('keydown', escape); addEventListener('deploy-canvas-reset', reset); + const media = () => { desktop = desktopQuery.matches; }; + desktopQuery.addEventListener('change', media); return () => { + desktopQuery.removeEventListener('change', media); theme.disconnect(); removeEventListener('keydown', escape); removeEventListener('deploy-canvas-reset', reset); @@ -69,20 +76,21 @@ }); -
    - -
    -
    - {#if cv.target} - - - {/if} - Beta -
    -
    - -
    +{#snippet header()} +
    + {#if cv.target} + + + {/if} + Beta
    + +{/snippet} + +
    + {#if !desktop} +
    {@render header()}
    + {/if} {#if noticeOpen}
    @@ -134,7 +142,6 @@ {/if}
    - els.pz.zoomTo(z)} onfit={fit} /> {#if cv.menu}
    @@ -149,6 +156,9 @@ {/if}
    + + els.pz.zoomTo(z)} onfit={fit} children={desktop ? header : null} /> + {#if cv.dropdown} {#key cv.dropdown?.trigger} diff --git a/resources/js/canvas/deploy/state.svelte.js b/resources/js/canvas/deploy/state.svelte.js index 60331ea9f2..2cfb6c3c24 100644 --- a/resources/js/canvas/deploy/state.svelte.js +++ b/resources/js/canvas/deploy/state.svelte.js @@ -85,62 +85,10 @@ export function pan(viewport) { canPan: e => [viewport, world, edges].includes(e.target), onPanStart: closeMenu, }); -} - -const DESKTOP = '(min-width: 1024px)'; -/** - * use:dock on the header. Desktop: it docks into the app top bar, or with the - * floating nav it is a separate panel next to the top bar (as on the - * environment canvas) when it fits there. Small screens: a box above the canvas. - */ -export function dock(top) { - const root = top.closest('.cv-root'); - const stage = root.querySelector('.cv-stage'); - function place() { - const desktop = matchMedia(DESKTOP).matches; - const slot = desktop && document.getElementById('page-toolbar-slot'); - const topbar = document.querySelector('.app-topbar'); - top.removeAttribute('style'); - if (desktop && topbar && document.documentElement.dataset.navStyle === 'floating') { - top.className = 'fixed z-40 flex items-center gap-3 rounded-lg border border-neutral-200 bg-white px-2 py-1.5 dark:border-white/[0.08] dark:bg-panel'; - root.append(top); - const gap = 8; - const head = topbar.getBoundingClientRect(); - const width = top.offsetWidth; - if (head.right + gap + width <= innerWidth - gap) { - // Centered over the canvas, but never over the top bar. - const box = stage.getBoundingClientRect(); - const left = Math.max(box.left + (box.width - width) / 2, head.right + gap); - Object.assign(top.style, { top: `${head.top}px`, left: `${left}px`, minHeight: `${head.height}px` }); - root.classList.add('cv-docked'); - return; - } - top.removeAttribute('style'); - } - top.className = slot ? 'flex items-center gap-3 pl-3' : 'cv-island cv-top'; - root.classList.toggle('cv-docked', !!slot); - if (slot) slot.replaceChildren(top); - else root.prepend(top); - } - place(); - const replace = () => root.isConnected && place(); - // The floating header is centered over the canvas next to the app top bar, so follow their sizes. - const sizes = new ResizeObserver(replace); - for (const target of [stage, document.querySelector('.app-topbar')].filter(Boolean)) sizes.observe(target); - const media = matchMedia(DESKTOP); - media.addEventListener('change', replace); - addEventListener('resize', replace); - const navStyle = new MutationObserver(replace); - navStyle.observe(document.documentElement, { attributes: true, attributeFilter: ['data-nav-style'] }); - return { - destroy() { - sizes.disconnect(); - media.removeEventListener('change', replace); - removeEventListener('resize', replace); - navStyle.disconnect(); - top.remove(); // it may sit in the app top bar - }, - }; + // The root paints the dots, so move them with the viewport when the Steps panel folds or the window resizes. + const sizes = new ResizeObserver(() => els.pz.apply()); + sizes.observe(viewport); + return { destroy: () => sizes.disconnect() }; } /** use:track on a card: its element (for measuring) and its size (for the lines). */ diff --git a/resources/js/canvas/deploy/styles.js b/resources/js/canvas/deploy/styles.js index e05434669b..869e76d236 100644 --- a/resources/js/canvas/deploy/styles.js +++ b/resources/js/canvas/deploy/styles.js @@ -22,6 +22,11 @@ const CSS = ` display: grid; grid-template-columns: 15.5rem minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); gap: 8px; padding: 8px; background: var(--cv-backdrop); color: var(--cv-text); + /* The dots cover the whole backdrop, also under the Steps panel. pan() + (canvas-core.js) lines them up with the viewport through these variables. */ + background-image: var(--canvas-dots-image, radial-gradient(circle, light-dark(rgb(0 0 0 / 0.13), rgb(255 255 255 / 0.09)) 1px, transparent 1.3px)); + background-size: var(--canvas-dots-size, 20px 20px); + background-position: var(--canvas-dots-position, 0 0); color-scheme: light; font: 13px/1.4 var(--font-sans, ui-sans-serif, system-ui, sans-serif); -webkit-font-smoothing: antialiased; } @@ -32,7 +37,7 @@ const CSS = ` .cv-root svg { width: 1rem; height: 1rem; flex: none; } .cv-root button:not(.button, [data-canvas-controls] button) { font: inherit; } - /* Header island on small screens; on desktop the header docks into the app top bar. */ + /* Header island on small screens; on desktop the header is in the zoom controls bar. */ .cv-top { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; padding: 0.625rem 0.75rem; } .cv-root.cv-docked { grid-template-rows: minmax(0, 1fr); } .cv-btn { @@ -61,7 +66,9 @@ const CSS = ` /* Palette island */ /* Hugs its steps instead of filling the row; scrolls when the screen is short. */ - .cv-palette { display: flex; flex-direction: column; align-self: start; max-height: 100%; overflow-y: auto; padding: 1rem 0.625rem 0.75rem; } + /* The zoom controls sit below the Steps panel, so the panel leaves room for them. */ + .cv-palette { display: flex; flex-direction: column; align-self: start; max-height: calc(100% - 3.25rem); overflow-y: auto; padding: 1rem 0.625rem 0.75rem; } + .cv-root > [data-canvas-controls] { bottom: 8px; left: 8px; } .cv-palette-head { padding: 0 0.5rem 0.75rem; margin: 0 -0.625rem 0.5rem; padding-inline: 1.125rem; border-bottom: 1px solid var(--cv-line); } .cv-palette-head b { display: block; font-size: 14px; font-weight: 600; } .cv-palette-head span { color: var(--cv-subtle); font-size: 12px; } @@ -95,7 +102,8 @@ const CSS = ` .cv-stage { position: relative; overflow: hidden; border: 0; background: transparent; } .cv-viewport { position: absolute; inset: 0; cursor: grab; touch-action: none; - background-image: radial-gradient(circle, light-dark(rgb(0 0 0 / 0.13), rgb(255 255 255 / 0.09)) 1px, transparent 1.3px); + /* The dots are on .cv-root, so the viewport has none of its own. */ + background-image: none; } .cv-viewport.panning { cursor: grabbing; } .cv-world { position: absolute; top: 0; left: 0; transform-origin: 0 0; } diff --git a/resources/js/canvas/environment/EmptyCard.svelte b/resources/js/canvas/environment/EmptyCard.svelte new file mode 100644 index 0000000000..dc6721258d --- /dev/null +++ b/resources/js/canvas/environment/EmptyCard.svelte @@ -0,0 +1,24 @@ + + + +
    + +
    +
    +
    {node.name}
    +
    {node.subtitle}
    +
    +
    diff --git a/resources/js/canvas/environment/EnvironmentCanvas.svelte b/resources/js/canvas/environment/EnvironmentCanvas.svelte index e8f854f30b..3baf907f12 100644 --- a/resources/js/canvas/environment/EnvironmentCanvas.svelte +++ b/resources/js/canvas/environment/EnvironmentCanvas.svelte @@ -17,6 +17,8 @@ filterGraph, filterOptions, fitView, + GROUP_HEADER, + GROUP_PADDING, groupByUse, groupFrames, groupKey, @@ -33,6 +35,7 @@ } from '../../environment-canvas.js'; import Card from './Card.svelte'; import Deployments from './Deployments.svelte'; + import EmptyCard from './EmptyCard.svelte'; import Icon from './Icon.svelte'; import Panel from './Panel.svelte'; import Toolbar from './Toolbar.svelte'; @@ -103,10 +106,12 @@ let pz; const canEdit = $derived(!!graph?.can_edit); - // Only the environment canvas saves card positions; server, project and connect views are auto-placed. + // Only the environment canvas has its own card positions; server and project views save on it (canMoveGroups). const canMove = $derived(canEdit && graph?.can_move !== false); - // Server canvas: admins drag whole project / environment groups; each environment saves its group position. + // Server canvas: admins drag whole project / environment groups and their cards; each environment saves them. const canMoveGroups = $derived(canEdit && !!graph?.can_move_groups); + /** Server view: key of the card's group when the card can be moved there (it is saved on its environment canvas). */ + const movableGroupOf = node => (canMoveGroups && node ? groupKey([node], node.zone) : null); let groupMeta = {}; // server view: frame top-left and zone inner top-left per group key (see layoutServerView) // Environment canvas endpoints of a card (the server view has no endpoints of its own for changes). const environmentBase = node => (serverView @@ -468,23 +473,32 @@ if (e.button !== 0 || e.target.closest('a,button,input,[data-ec-port]')) return; e.stopPropagation(); const start = { cx: e.clientX, cy: e.clientY, ...positions[id] }; + const key = movableGroupOf(nodeById(id)); + const meta = key ? groupMeta[key] : null; + const movable = canMove || !!meta; // The other cards and frames stay put during the drag, so measure them once. const rules = dragRules(graph, positions, sizes, id); + // Server view: the group frame stays inside its zone, below the zone header. + const fits = meta + ? b => b.x >= meta.inner.x + GROUP_PADDING && b.y >= meta.inner.y + GROUP_HEADER + GROUP_PADDING && rules.fits(b) + : rules.fits; let moved = false; capture(card, e.pointerId); begin(`card:${id}`); track(card, ev => { if (!moved && Math.hypot(ev.clientX - start.cx, ev.clientY - start.cy) < 4) return; - if (!canMove) return; + if (!movable) return; const rect = rectOf(id); const wanted = { x: start.x + (ev.clientX - start.cx) / view.z, y: start.y + (ev.clientY - start.cy) / view.z, w: rect.width, h: rect.height }; // Cards cannot cover each other or another zone: stop at the edge of what is in the way. - const spot = freeSpot(wanted, rules.others, BOX_GAP, rules.fits); + const spot = freeSpot(wanted, rules.others, BOX_GAP, fits); if (!spot) return; moved = true; positions[id] = { x: spot.x, y: spot.y }; }, () => { - if (moved) queueSave({ [id]: { ...positions[id] } }); + // Server view: save the whole group, so its other cards keep the place they have here. + if (moved && meta) saveGroup(graph.nodes.filter(n => groupKey([n], n.zone) === key).map(n => n.id), meta.shift); + else if (moved) queueSave({ [id]: { ...positions[id] } }); else select({ kind: 'node', id }); end(`card:${id}`); }); @@ -933,24 +947,28 @@
    {#each graph?.nodes || [] as node (node.id)} - startCardDrag(node.id, card, e)} - onconnect={(e, port) => startConnect(node.id, port, e)} - onselect={() => select({ kind: 'node', id: node.id })} - onhover={on => hoverCard(node.id, on)} - /> + {#if node.type === 'empty'} + + {:else} + startCardDrag(node.id, card, e)} + onconnect={(e, port) => startConnect(node.id, port, e)} + onselect={() => select({ kind: 'node', id: node.id })} + onhover={on => hoverCard(node.id, on)} + /> + {/if} {/each}
    diff --git a/resources/js/deploy-canvas.test.js b/resources/js/deploy-canvas.test.js index e4f9be5461..a8fbecc2d7 100644 --- a/resources/js/deploy-canvas.test.js +++ b/resources/js/deploy-canvas.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { freeSpot } from './canvas-core.js'; +import { freeSpot, zoomShortcut } from './canvas-core.js'; import { canFollow, domainList, fresh, logoOf, onlyOption, parseEnvVars, planOf, splitImage, targetFrom, uniqueLabels, withDeploy, withoutSecrets, withTarget } from './deploy-canvas.js'; test('splits the tag out of an image name', () => { @@ -176,3 +176,14 @@ test('shows the logo of what a source step creates, like the rest of Coolify', ( assert.equal(src('database', {}), null); assert.equal(src('server', {}), null); }); + +test('reads the browser zoom shortcut so the canvas can zoom instead', () => { + const key = (key, mods = {}) => zoomShortcut({ key, ctrlKey: false, metaKey: false, altKey: false, ...mods }); + assert.equal(key('=', { metaKey: true }), 'in'); + assert.equal(key('+', { ctrlKey: true }), 'in'); + assert.equal(key('-', { ctrlKey: true }), 'out'); + assert.equal(key('0', { metaKey: true }), 'reset'); + assert.equal(key('-'), null); + assert.equal(key('-', { ctrlKey: true, altKey: true }), null); + assert.equal(key('s', { metaKey: true }), null); +}); diff --git a/resources/js/environment-canvas.js b/resources/js/environment-canvas.js index 3149f366d3..9faeb3b3fe 100644 --- a/resources/js/environment-canvas.js +++ b/resources/js/environment-canvas.js @@ -135,7 +135,8 @@ const byTypeAndName = (a, b) => columnOf(a) - columnOf(b) || String(a.name).loca /** * Card positions inside one group, from (0, 0). Cards with a position saved on their environment - * canvas (node.saved) keep that arrangement; the other cards follow below them. Otherwise linked + * canvas (node.saved) keep that arrangement; the other cards follow below them in the grid the environment + * canvas uses, so both canvases show the group the same way. Otherwise linked * cards form chains from left to right (source before target, one column per step); every chain * gets its own rows. Cards without links follow in a grid of SINGLE_COLUMNS. */ @@ -150,9 +151,9 @@ export function layoutBlock(nodes, links = []) { const bottom = Math.max(...saved.map(n => out[n.id].y + estimateNodeHeight(n))); const rest = nodes.filter(n => !out[n.id]); if (!rest.length) return { positions: out, width, height: bottom }; - const below = layoutBlock(rest, links); - for (const [id, p] of Object.entries(below.positions)) out[id] = { x: p.x, y: bottom + ROW_GAP + p.y }; - return { positions: out, width: Math.max(width, below.width), height: bottom + ROW_GAP + below.height }; + // Same grid as the environment canvas (layoutGraph) puts below the saved cards, so both canvases match. + const height = placeGrid(rest, 0, bottom + ROW_GAP, out); + return { positions: out, width: Math.max(...Object.values(out).map(p => p.x)) + CARD_WIDTH, height }; } const ids = new Set(nodes.map(n => n.id)); const inside = links.filter(l => ids.has(l.source) && ids.has(l.target) && l.source !== l.target); @@ -217,9 +218,12 @@ export function layoutBlock(nodes, links = []) { return { positions: out, width: columns * step - COLUMN_GAP, height: bottom }; } -/** Server view: key of a project / environment group on one server (":"). */ +/** + * Server view: key of a project / environment group on one server (":"). + * Groups of an empty environment (placeholder card) have none, so they cannot be moved. + */ export const groupKey = (nodes, zoneId) => { - const environment = nodes.find(n => n.environment?.uuid)?.environment?.uuid; + const environment = nodes.find(n => n.type !== 'empty' && n.environment?.uuid)?.environment?.uuid; return environment && zoneId ? `${environment}:${zoneId}` : null; }; @@ -230,7 +234,7 @@ export const groupKey = (nodes, zoneId) => { * GROUPS_PER_ROW per row. When `groups` is given, it gets per group key its frame top-left, the zone's inner * top-left, and `shift`: environment canvas position = server view position - shift. */ -export function layoutServerView(graph, groups = null) { +export function layoutServerView(graph, groups = null, { compact = false } = {}) { const out = {}; let y = 0; for (const zone of zoneGroups(graph)) { @@ -240,15 +244,20 @@ export function layoutServerView(graph, groups = null) { const block = layoutBlock(nodes, graph.links || []); return { key: groupKey(nodes, zone.id), + nodes, block, size: { w: block.width + GROUP_PADDING * 2, h: GROUP_HEADER + GROUP_PADDING * 2 + block.height }, at: saved.length ? { x: Math.min(...saved.map(n => Number(n.saved.x))), y: Math.min(...saved.map(n => Number(n.saved.y))) } : null, }; }); + // Empty environments (placeholder cards only) go next to the other groups after these are placed. + const isEmpty = b => b.nodes.every(n => n.type === 'empty'); const pinned = blocks.filter(b => b.at).sort((a, b) => a.at.y - b.at.y || a.at.x - b.at.x); // The environment canvas puts its first cards below a zone header; those land at the zone's top-left here. const shift = { x: inner.x + GROUP_PADDING, y: inner.y + GROUP_HEADER + GROUP_PADDING - ZONE_HEADER - ZONE_PADDING }; + const frames = []; const place = ({ key, block, size }, left, top) => { + frames.push({ x: left, y: top }); for (const [id, p] of Object.entries(block.positions)) { out[id] = { x: left + GROUP_PADDING + p.x, y: top + GROUP_HEADER + GROUP_PADDING + p.y }; } @@ -266,21 +275,39 @@ export function layoutServerView(graph, groups = null) { ...b.size, }; const spot = freeSpot(wanted, taken, GROUP_GAP, inside) || wanted; - taken.push(spot); + taken.push({ ...spot, project: b.nodes[0]?.project?.uuid }); bottom = Math.max(bottom, place(b, spot.x, spot.y)); } let x = 0; let rowTop = pinned.length ? bottom + GROUP_GAP : inner.y; let rowBottom = rowTop; - blocks.filter(b => !b.at).forEach((b, i) => { + blocks.filter(b => !b.at && !isEmpty(b)).forEach((b, i) => { if (i > 0 && i % GROUPS_PER_ROW === 0) { x = 0; rowTop = rowBottom + GROUP_GAP; } + taken.push({ x, y: rowTop, ...b.size, project: b.nodes[0]?.project?.uuid }); rowBottom = Math.max(rowBottom, place(b, x, rowTop)); x += b.size.w + GROUP_GAP; }); - y = Math.max(bottom, rowBottom) + ZONE_PADDING + ZONE_GAP; + bottom = Math.max(bottom, rowBottom); + // Right of the last group of the same project (or of the zone), at the nearest free spot. + for (const b of blocks.filter(b => !b.at && isEmpty(b))) { + const project = b.nodes[0]?.project?.uuid; + const anchor = taken.filter(t => t.project === project).at(-1) || taken.at(-1); + const wanted = anchor ? { x: anchor.x + anchor.w + GROUP_GAP, y: anchor.y, ...b.size } : { ...inner, ...b.size }; + const spot = freeSpot(wanted, taken, GROUP_GAP, inside) || wanted; + taken.push({ ...spot, project }); + bottom = Math.max(bottom, place(b, spot.x, spot.y)); + } + if (compact && frames.length) { + // The groups keep their arrangement, but start at the zone's top-left (no empty space from saved positions). + const dx = inner.x - Math.min(...frames.map(f => f.x)); + const dy = inner.y - Math.min(...frames.map(f => f.y)); + for (const n of zone.nodes) if (out[n.id]) out[n.id] = { x: out[n.id].x + dx, y: out[n.id].y + dy }; + bottom += dy; + } + y = bottom + ZONE_PADDING + ZONE_GAP; } return out; } @@ -300,7 +327,7 @@ export function layoutConnectView(graph) { out[n.id] = { x: 0, y }; y += estimateNodeHeight(n) + ROW_GAP; } - const offset = variables ? CARD_WIDTH + ZONE_PADDING * 2 + ZONE_GAP * 3 : 0; + const offset = variables ? CARD_WIDTH + ZONE_PADDING * 2 + ZONE_GAP * 2 : 0; let top = 0; if (connected) { y = ZONE_HEADER + ZONE_PADDING; @@ -315,7 +342,8 @@ export function layoutConnectView(graph) { top = y - GROUP_GAP + ZONE_PADDING + ZONE_GAP; } const others = groups.filter(g => g.id !== SOURCES_ZONE && g.id !== CONNECTED_ZONE); - const placed = layoutServerView({ zones: others.map(g => g.zone || { id: g.id }), nodes: others.flatMap(g => g.nodes) }); + // Compact: groups cannot be moved here, so positions saved on the environment canvas must not leave gaps. + const placed = layoutServerView({ zones: others.map(g => g.zone || { id: g.id }), nodes: others.flatMap(g => g.nodes) }, null, { compact: true }); for (const [id, p] of Object.entries(placed)) out[id] = { x: p.x + offset, y: p.y + top }; return out; } diff --git a/resources/js/environment-canvas.test.js b/resources/js/environment-canvas.test.js index 920f2bc841..e4240b62bf 100644 --- a/resources/js/environment-canvas.test.js +++ b/resources/js/environment-canvas.test.js @@ -7,6 +7,7 @@ import { ROW_GAP, ZONE_HEADER, ZONE_PADDING, + ZONE_GAP, canConnect, canLink, dragRules, @@ -21,6 +22,7 @@ import { statusWord, READABLE_ZOOM, groupFrames, + groupKey, layoutBlock, estimateNodeHeight, layoutGraph, @@ -291,6 +293,28 @@ test('server view: cards keep the arrangement saved on their environment canvas, assert.ok(p.c.y >= p.b.y + estimateNodeHeight(g.nodes[1]) + ROW_GAP); }); +test('server view: a group with one saved card matches the environment canvas arrangement', () => { + const apps = Array.from({ length: MAX_ROWS + 1 }, (_, i) => envNode(`n${i}`, 'application', 'z1', 'prod')); + const nodes = [envNode('a', 'application', 'z1', 'prod', { saved: { x: 1000, y: 697 } }), ...apps, envNode('d', 'database', 'z1', 'prod')]; + const env = layoutGraph({ zones: [{ id: 'z1', name: 'web' }], nodes, links: [], positions: { a: { x: 1000, y: 697 } } }); + const server = layoutServerView({ layout: 'server', zones: [{ id: 'z1', name: 'web' }], nodes, links: [{ source: 'n0', target: 'd' }] }); + for (const n of nodes) { + assert.deepEqual({ x: server[n.id].x - server.a.x, y: server[n.id].y - server.a.y }, { x: env[n.id].x - env.a.x, y: env[n.id].y - env.a.y }, n.id); + } +}); + +test('server view: a moved card saved with its group on the environment canvas stays where it was dropped', () => { + const nodes = [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'service', 'z1', 'prod'), envNode('d', 'database', 'z1', 'prod')]; + const g = { layout: 'server', zones: [{ id: 'z1', name: 'web' }], nodes, links: [] }; + const groups = {}; + const before = layoutServerView(g, groups); + const { shift } = groups['prod:z1']; + const dropped = { ...before, b: { x: before.b.x + 120, y: before.b.y + 400 } }; + const saved = Object.fromEntries(nodes.map(n => [n.id, { x: dropped[n.id].x - shift.x, y: dropped[n.id].y - shift.y }])); + const after = layoutServerView({ ...g, nodes: nodes.map(n => ({ ...n, saved: saved[n.id] })) }); + assert.deepEqual(after, dropped); +}); + test('server view: zones with server details get the taller header', () => { const g = { layout: 'server', @@ -561,3 +585,43 @@ test('server view: a dragged card stops before its group covers another group', const [f1, f2] = groupFrames(g.nodes, positions); assert.ok(apart(f1, f2), 'the project / environment frames do not overlap'); }); + +test('groupKey: a group with only an empty-environment placeholder has no key, so it cannot be moved', () => { + const environment = { uuid: 'env', name: 'production' }; + assert.equal(groupKey([{ id: 'empty:env', type: 'empty', environment }], 'empty'), null); + assert.equal(groupKey([{ id: 'a', type: 'application', environment }], 'srv'), 'env:srv'); +}); + +test('layoutServerView: an empty environment sits right of the other groups of its project, not below them', () => { + const shop = { uuid: 'shop', name: 'Shop' }; + const node = (id, context, extra = {}) => ({ id, type: 'application', zone: 'srv', context, project: shop, environment: { uuid: context }, ...extra }); + const graph = { + layout: 'server', + zones: [{ id: 'srv', name: 'srv' }], + nodes: [ + node('a', 'Shop / staging', { saved: { x: 0, y: 0 } }), + node('empty:prod', 'Shop / production', { type: 'empty' }), + ], + links: [], + }; + const out = layoutServerView(graph); + assert.equal(out['empty:prod'].y, out.a.y); + assert.ok(out['empty:prod'].x >= out.a.x + CARD_WIDTH); +}); + +test('variables view: saved environment canvas positions do not push resources away from the variables', () => { + const g = { + layout: 'connect', + zones: [{ id: 'sources', name: 'Server variables', icon: 'variable' }, { id: 'resources', name: 'Other resources', icon: 'service' }], + nodes: [ + node('variable:1', 'variable', 'sources', { name: 'COOLIFY_SERVER_NAME' }), + node('app', 'application', 'resources', { context: 'Shop / prod', environment: { uuid: 'e1' }, saved: { x: 900, y: 1200 } }), + ], + links: [], + positions: {}, + }; + const [variables, resources] = zoneBoxes(g, layoutGraph(g)); + + assert.equal(resources.y, variables.y, 'both zones start at the top'); + assert.ok(resources.x - (variables.x + variables.width) <= ZONE_GAP * 2, 'resources sit next to the variables'); +}); diff --git a/resources/views/components/application/configuration-sidebar.blade.php b/resources/views/components/application/configuration-sidebar.blade.php index 93934db322..8b61ea5b72 100644 --- a/resources/views/components/application/configuration-sidebar.blade.php +++ b/resources/views/components/application/configuration-sidebar.blade.php @@ -1,4 +1,4 @@ -@props(['application', 'currentRoute', 'flush' => false]) +@props(['application', 'currentRoute', 'flush' => false, 'navOnly' => false]) @php $applicationRouteParameters = [ @@ -258,17 +258,14 @@ @php // The main sidebar shows this menu on wide screens (see components/navbar.blade.php). - request()->attributes->set('mainNavContext', [ - 'label' => 'Dashboard', - 'backHref' => route('dashboard'), - 'backTitle' => 'Back to the dashboard', - ]); + registerMainNavContext(0, 'Dashboard', 'Back to the main menu'); @endphp -{{-- Rendered twice: in the page (below xl), and teleported into the main sidebar (xl and up). --}} -@foreach (['page', 'main-nav'] as $placement) +{{-- Rendered twice: in the page (below xl), and teleported into the main sidebar (xl and up). + navOnly renders only the sidebar copy, as the parent menu of a deeper page (e.g. a backup). --}} +@foreach ($navOnly ? ['main-nav'] : ['page', 'main-nav'] as $placement) @if ($placement === 'main-nav') -@teleport('#main-nav-context') +@teleport('#main-nav-context-0')