From 62b2a99a7061aacddf25502d76aa6137dfad77cf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:25:43 +0100 Subject: [PATCH] chore: merge main into next (#12351) Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Co-authored-by: Aditya Tripathi Co-authored-by: Claude Opus 5.5 --- .ai/lessons.md | 6 +- app/Actions/Database/StartDatabaseImport.php | 2 +- app/Actions/Database/StartDatabaseProxy.php | 28 +- .../Development/StartDevelopmentQemuVm.php | 8 + app/Actions/Docker/GetContainersStatus.php | 10 +- app/Actions/Server/StartLogDrain.php | 82 +-- app/Actions/Server/ValidateServer.php | 13 + .../Service/DeployServiceApplication.php | 1 + app/Actions/Service/StartService.php | 14 + app/Actions/User/DeleteUserTeams.php | 1 - app/Auth/Oidc/OidcDiscoveryService.php | 4 +- app/Exceptions/CloudProviderApiException.php | 73 +++ .../Api/ApplicationsController.php | 27 +- .../Api/Concerns/RequiresDeployAbility.php | 7 +- .../Controllers/Api/DatabasesController.php | 39 +- .../Api/DigitalOceanController.php | 23 +- .../Controllers/Api/HetznerController.php | 70 +- .../Controllers/Api/HostingerController.php | 14 +- app/Http/Controllers/Api/OpenApi.php | 108 ++++ .../Controllers/Api/ResourcesController.php | 19 +- .../Controllers/Api/S3StoragesController.php | 11 +- .../Api/ServerLogDrainsController.php | 8 +- .../Api/ServiceApplicationsController.php | 3 +- .../Api/ServiceDatabasesController.php | 2 +- .../Controllers/Api/ServicesController.php | 13 +- app/Http/Controllers/Api/VultrController.php | 23 +- app/Http/Controllers/Webhook/Github.php | 2 - app/Http/Middleware/ApiAllowed.php | 2 +- app/Http/Middleware/ApiIpAllowed.php | 2 +- app/Jobs/ApplicationDeploymentJob.php | 64 +- app/Jobs/ApplicationPullRequestUpdateJob.php | 6 +- app/Jobs/DatabaseBackupJob.php | 2 +- app/Jobs/PushServerUpdateJob.php | 16 +- app/Jobs/ServerCheckJob.php | 2 +- .../ServerCloudProviderStatusCheckJob.php | 20 +- app/Jobs/ServerFilesFromServerJob.php | 3 +- app/Jobs/ValidateNewCloudServerJob.php | 75 +++ app/Jobs/VolumeBackupJob.php | 2 +- app/Livewire/Analytics.php | 7 +- app/Livewire/Destination/Show.php | 1 + app/Livewire/Project/Application/Domains.php | 10 +- app/Livewire/Project/Application/General.php | 20 +- app/Livewire/Project/Application/Previews.php | 2 +- app/Livewire/Project/Application/Source.php | 7 +- app/Livewire/Project/CloneMe.php | 2 +- .../Project/Database/Clickhouse/General.php | 1 + .../Project/Database/Dragonfly/General.php | 1 + app/Livewire/Project/Database/ImportForm.php | 2 +- .../Project/Database/Keydb/General.php | 1 + .../Project/Database/Mariadb/General.php | 1 + .../Project/Database/Mongodb/General.php | 1 + .../Project/Database/Mysql/General.php | 1 + .../Project/Database/Postgresql/General.php | 1 + .../Project/Database/Redis/General.php | 1 + .../Project/Database/Sqlite/General.php | 1 + app/Livewire/Project/Edit.php | 1 + .../Project/New/GiteaPrivateRepository.php | 4 +- .../Project/New/GithubPrivateRepository.php | 38 +- .../New/GithubPrivateRepositoryDeployKey.php | 9 +- .../Project/New/GitlabPrivateRepository.php | 4 +- .../Project/New/PublicGitRepository.php | 3 +- app/Livewire/Project/Service/Index.php | 34 +- app/Livewire/Project/Service/StackForm.php | 1 + .../Shared/EnvironmentVariable/All.php | 49 +- app/Livewire/Project/Shared/GetLogs.php | 5 +- .../Project/Shared/ResourceOperations.php | 2 +- .../Project/Shared/ScheduledTask/All.php | 4 +- .../Security/IntegrationTokenEditor.php | 3 + .../Security/IntegrationTokenForm.php | 3 + app/Livewire/Server/LogDrains.php | 26 +- app/Livewire/Server/New/ByDigitalOcean.php | 3 + app/Livewire/Server/New/ByHetzner.php | 65 +- app/Livewire/Server/New/ByHostinger.php | 3 + app/Livewire/Server/New/ByVultr.php | 3 + app/Livewire/Server/Show.php | 12 - app/Livewire/Server/ValidateAndInstall.php | 16 + .../SharedVariables/Environment/Show.php | 33 +- app/Livewire/SharedVariables/Project/Show.php | 34 +- app/Livewire/SharedVariables/Server/Show.php | 38 +- app/Livewire/SharedVariables/Team/Index.php | 33 +- app/Livewire/Source/Gitea/Change.php | 1 + app/Livewire/Source/Github/Change.php | 1 + app/Livewire/Source/Gitlab/Change.php | 1 + app/Livewire/Storage/Create.php | 15 + app/Livewire/Storage/Form.php | 29 +- app/Livewire/SwitchTeam.php | 5 +- app/Livewire/Team/Index.php | 1 + app/Livewire/Team/Member.php | 35 +- app/Models/Application.php | 47 +- app/Models/EnvironmentVariable.php | 8 + app/Models/LocalFileVolume.php | 62 +- app/Models/S3Storage.php | 83 ++- app/Models/Server.php | 16 +- app/Models/ServerSetting.php | 11 +- app/Models/SharedEnvironmentVariable.php | 9 + app/Models/StandaloneKeydb.php | 2 +- app/Models/User.php | 1 - app/Providers/AppServiceProvider.php | 8 + app/Rules/DockerImageFormat.php | 15 +- app/Services/CloudflareTokenValidator.php | 11 +- app/Services/CloudflareTunnelService.php | 2 +- app/Services/DigitalOceanService.php | 3 +- app/Services/Dns/CloudflareDnsProvider.php | 2 +- app/Services/DockerImageParser.php | 16 +- app/Services/DopplerService.php | 4 +- app/Services/FileDownloadStorageService.php | 2 +- app/Services/HetznerService.php | 48 +- app/Services/IntegrationTokenValidator.php | 7 + app/Services/SentinelTrafficClient.php | 84 ++- .../ServerTransfer/ServerTransferExporter.php | 1 + .../ServerTransfer/ServerTransferImporter.php | 1 + app/Services/VultrService.php | 3 +- app/Support/RetryHttpConnectionFailures.php | 47 ++ app/Support/ValidationPatterns.php | 24 +- bootstrap/helpers/databases.php | 2 +- bootstrap/helpers/domains.php | 38 ++ bootstrap/helpers/github.php | 9 +- bootstrap/helpers/parsers.php | 40 +- bootstrap/helpers/services.php | 54 +- bootstrap/helpers/shared.php | 129 ++++ ...th_style_endpoint_to_s3_storages_table.php | 22 + ..._axiom_domain_to_server_settings_table.php | 22 + ...t_log_drain_from_server_settings_table.php | 43 ++ docker/coolify-terminal/terminal-server.js | 45 +- docker/coolify-terminal/terminal-utils.js | 98 ++- .../coolify-terminal/terminal-utils.test.js | 76 ++- docs/server-transfer-full-coverage.md | 2 +- openapi.json | 604 ++++++++++++++++-- openapi.yaml | 394 ++++++++++-- other/nightly/versions.json | 6 +- resources/js/terminal-connection.js | 28 +- resources/js/terminal-connection.test.js | 17 +- resources/js/terminal.js | 12 +- .../components/breadcrumb-switcher.blade.php | 10 +- .../components/modal-confirmation.blade.php | 18 +- .../shared-variables/editor.blade.php | 6 +- .../views/components/top-breadcrumb.blade.php | 24 +- .../shared/environment-variable/all.blade.php | 2 + .../livewire/server/log-drains.blade.php | 9 +- .../livewire/server/new/by-hetzner.blade.php | 4 +- .../views/livewire/storage/create.blade.php | 11 +- .../views/livewire/storage/form.blade.php | 10 +- scripts/transfer-test/seed-full.php | 2 +- .../Api/DatabaseApiResponseSchemaTest.php | 88 +++ .../Api/EnvironmentVariableUpdateApiTest.php | 162 +++++ tests/Feature/Api/HetznerApiTest.php | 328 +++++++++- tests/Feature/Api/S3StoragesApiTest.php | 32 + tests/Feature/Api/ServerSubsystemsApiTest.php | 20 + tests/Feature/Api/ServerTransferApiTest.php | 32 + .../Api/StoppedContainerLogsApiTest.php | 125 ++++ tests/Feature/ApiAccessDeniedResponseTest.php | 53 ++ .../ApiTokenTeamLifecycleSecurityTest.php | 48 ++ .../ApplicationDeploymentComposeParseTest.php | 103 +++ tests/Feature/ApplicationDomainsTest.php | 8 +- .../ApplicationLegacyBuildCommandTest.php | 130 ++++ ...licationParserDockerComposeDomainsTest.php | 80 +++ tests/Feature/ApplicationPreviewApiTest.php | 2 +- ...ApplicationRestartLimitEnforcementTest.php | 103 +++ ...eyApplicationCreationAuthorizationTest.php | 45 ++ tests/Feature/BackupS3CredentialsArgvTest.php | 30 + tests/Feature/BreadcrumbRenameEventsTest.php | 60 ++ tests/Feature/CloneServiceVolumeDataTest.php | 15 + .../ComposeContentVolumeConfinementTest.php | 3 +- .../ContentFilesBeforeComposeUpTest.php | 62 +- .../DatabaseEnvironmentVariableApiTest.php | 23 + .../DatabaseImportInterruptedCleanupTest.php | 12 +- .../DatabaseProxyConfigRefreshTest.php | 147 +++++ tests/Feature/DevelopmentQemuVmTest.php | 18 + tests/Feature/DigitalOceanApiTest.php | 44 ++ .../DigitalOceanServerCreationTest.php | 4 + tests/Feature/DnsProviderManagementTest.php | 20 + ...nvironmentVariableDevViewDirectiveTest.php | 146 +++++ ...nvironmentVariableDevViewRoundTripTest.php | 79 +++ tests/Feature/FileStorageSyncTriggersTest.php | 123 ++++ tests/Feature/GetLogsInvalidUtf8Test.php | 76 +++ tests/Feature/GithubPrivateRepositoryTest.php | 46 ++ .../GithubRepositoryProjectIdSshUrlTest.php | 77 +++ tests/Feature/HetznerServerValidationTest.php | 86 +++ tests/Feature/HostingerApiTest.php | 5 + tests/Feature/HostingerServerCreationTest.php | 4 + tests/Feature/HttpConnectionRetryTest.php | 40 ++ ...neralDockerRegistryImageValidationTest.php | 14 + .../LocalFileVolumeRelativePathTest.php | 2 + tests/Feature/LogDrain/AxiomLogDrainTest.php | 137 ++++ .../LogDrain/CloudwatchLogDrainTest.php | 46 -- .../LogDrain/LogDrainConfigRefreshTest.php | 179 ++++++ .../RemoveHighlightLogDrainMigrationTest.php | 30 + tests/Feature/ModelFillableCreationTest.php | 2 - .../NewApplicationRepositoryFlowsTest.php | 185 ++++++ .../PersistentStoragePerformanceTest.php | 8 +- .../PreviewDomainPortOverridesTest.php | 9 +- tests/Feature/PreviewFileStoragePathTest.php | 94 +++ ...efikApplicationRouterServiceLabelsTest.php | 68 ++ .../Feature/S3StorageCreateErrorLinkTest.php | 16 + tests/Feature/S3StorageFormTest.php | 2 + .../Security/IntegrationTokenFormTest.php | 47 ++ .../Server/HetznerServerCreationTest.php | 94 ++- .../Server/HetznerServerPlaceholderIpTest.php | 5 + .../Server/ServerIpv6NormalizationTest.php | 4 + tests/Feature/ServerSettingsAuditTest.php | 4 + .../Feature/ServerStorageSaveDispatchTest.php | 8 +- tests/Feature/StorageAndServiceAuditTest.php | 10 +- tests/Feature/StorageHostPathTest.php | 4 +- .../TrafficAnalytics/GlobalAnalyticsTest.php | 31 + .../SentinelTrafficAvailabilityTest.php | 3 +- .../Feature/ValidateNewCloudServerJobTest.php | 100 +++ tests/Feature/VultrApiTest.php | 44 ++ tests/Feature/VultrServerCreationTest.php | 4 + .../application-with-exact-caddy-labels.yaml | 46 +- ...application-with-exact-traefik-labels.yaml | 46 +- .../expected/application-with-settings.yaml | 46 +- .../Fixtures/parser/expected/application.yaml | 46 +- tests/Support/RemoteProcessTestHelpers.php | 8 + tests/Unit/CloneResourceNameTest.php | 25 + tests/Unit/DockerImageParserTest.php | 12 + .../DockerImageReferenceValidationTest.php | 45 ++ tests/Unit/EnvDevViewDirectiveTest.php | 91 +++ tests/Unit/FormatEnvValueForDevViewTest.php | 25 + tests/Unit/GitImportStepsTest.php | 36 ++ tests/Unit/GitLfsSkipSmudgeTest.php | 92 +++ tests/Unit/LocalFileVolumeContentSizeTest.php | 21 +- tests/Unit/LogDrainCommandInjectionTest.php | 10 - tests/Unit/ParseCommandsByLineForSudoTest.php | 8 + tests/Unit/ResourceCreateOpenApiTest.php | 24 + tests/Unit/S3StorageTest.php | 83 +++ tests/v4/Browser/BreadcrumbRenameTest.php | 37 ++ tests/v4/Browser/DeleteAccountTest.php | 4 + .../S3StoragePathStyleDetectionTest.php | 35 + versions.json | 6 +- 229 files changed, 6966 insertions(+), 1042 deletions(-) create mode 100644 app/Exceptions/CloudProviderApiException.php create mode 100644 app/Jobs/ValidateNewCloudServerJob.php create mode 100644 app/Support/RetryHttpConnectionFailures.php create mode 100644 database/migrations/2026_10_27_142752_add_use_path_style_endpoint_to_s3_storages_table.php create mode 100644 database/migrations/2026_10_27_155023_add_logdrain_axiom_domain_to_server_settings_table.php create mode 100644 database/migrations/2026_10_27_160258_remove_highlight_log_drain_from_server_settings_table.php create mode 100644 tests/Feature/Api/DatabaseApiResponseSchemaTest.php create mode 100644 tests/Feature/Api/StoppedContainerLogsApiTest.php create mode 100644 tests/Feature/ApiAccessDeniedResponseTest.php create mode 100644 tests/Feature/ApplicationDeploymentComposeParseTest.php create mode 100644 tests/Feature/ApplicationLegacyBuildCommandTest.php create mode 100644 tests/Feature/ApplicationRestartLimitEnforcementTest.php create mode 100644 tests/Feature/BreadcrumbRenameEventsTest.php create mode 100644 tests/Feature/DatabaseProxyConfigRefreshTest.php create mode 100644 tests/Feature/EnvironmentVariableDevViewDirectiveTest.php create mode 100644 tests/Feature/EnvironmentVariableDevViewRoundTripTest.php create mode 100644 tests/Feature/FileStorageSyncTriggersTest.php create mode 100644 tests/Feature/GetLogsInvalidUtf8Test.php create mode 100644 tests/Feature/GithubRepositoryProjectIdSshUrlTest.php create mode 100644 tests/Feature/HetznerServerValidationTest.php create mode 100644 tests/Feature/HttpConnectionRetryTest.php create mode 100644 tests/Feature/LogDrain/AxiomLogDrainTest.php create mode 100644 tests/Feature/LogDrain/LogDrainConfigRefreshTest.php create mode 100644 tests/Feature/LogDrain/RemoveHighlightLogDrainMigrationTest.php create mode 100644 tests/Feature/NewApplicationRepositoryFlowsTest.php create mode 100644 tests/Feature/PreviewFileStoragePathTest.php create mode 100644 tests/Feature/Proxy/TraefikApplicationRouterServiceLabelsTest.php create mode 100644 tests/Feature/ValidateNewCloudServerJobTest.php create mode 100644 tests/Unit/CloneResourceNameTest.php create mode 100644 tests/Unit/EnvDevViewDirectiveTest.php create mode 100644 tests/Unit/FormatEnvValueForDevViewTest.php create mode 100644 tests/Unit/GitLfsSkipSmudgeTest.php create mode 100644 tests/Unit/ResourceCreateOpenApiTest.php create mode 100644 tests/v4/Browser/BreadcrumbRenameTest.php create mode 100644 tests/v4/Browser/S3StoragePathStyleDetectionTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index 44f496dd05..23292f3729 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -30,11 +30,15 @@ - In browser tests, address `x-forms.input` fields by `name` (`type('fieldName', ...)`); its HTML `id` is not always the Livewire property, so `#property` waits forever. - For load tests against a dev VM, use your own instance (`COOLIFY_DEV_INSTANCE= COOLIFY_DEV_SIDE_BY_SIDE=1 ./scripts/dev start`); other sessions can reseed the localhost VM of a shared instance. The 1 GiB VM drops packets when its conntrack table is full (`dmesg`: `nf_conntrack: table full`), so raise `net.netfilter.nf_conntrack_max` on it and run an idle load first to prove a clean baseline. - Do not call `sleep()` in a browser test. The app server runs in the test process, so `sleep()` stops it; use `$page->wait()`. -- `Queue::fake()` also captures `dispatchSync()` of a `ShouldQueue` job, so the job never runs. Fake only the jobs to block (`Queue::fake([SomeJob::class])`). +- `Queue::fake()` also captures `dispatchSync()` of a `ShouldQueue` job, so the job never runs. Fake only the jobs to block (`Queue::fake([SomeJob::class])`). Exception: actions (`AsAction`) are queued as `JobDecorator`, so `Queue::fake([SomeAction::class])` does not stop them; use `Queue::fake()` and `SomeAction::assertPushed()`. +- `Notification::fake()` records nothing for a team that has no enabled channel. Turn one on (for example `emailNotificationSettings()->update(['use_instance_email_settings' => true, '_email_notifications' => true])`) before `assertSentTo`. - In tests, turning on `is_metrics_enabled` (and other Sentinel settings) with `save()`/`update()` runs the settings hook, which starts Sentinel over a real SSH connection on the sync queue. Use `saveQuietly()` or `Queue::fake()`. - 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`. - The root team (id 0) has `personal_team = true`, so `personal_team` does not identify a user's own team. To clean up test users, detach them from shared teams and then delete the user; never delete a team selected by a filter. A Team delete revokes the team's API tokens and deletes its private keys before any guard can stop it. - SQLite returns `0`/`1` for boolean columns without a `boolean` cast, while Postgres returns real booleans. A strict `=== false` check on such a column never matches in tests, so tests can pass on behavior that Postgres blocks. Add the cast or compare loosely. +- SQLite does not enforce `varchar(255)` lengths, while Postgres rejects a longer value (`SQLSTATE[22001]`). Tests cannot show a too-long name, so assert the length or the exact value of generated strings. + +- The Compose parsers (`parse()`, `applicationParser`, `serviceParser`) run on page mounts, refreshes, and deployments. Do not dispatch jobs or run server commands from them; let the caller that changes data start the work. Code that only needs the service names reads `docker_compose_raw` (`Application::composeServicesWithoutParse()`). ## Verify the complete user flow - Do not use a passing unit test, a successful build, or a healthy process as proof for a reported UI failure. diff --git a/app/Actions/Database/StartDatabaseImport.php b/app/Actions/Database/StartDatabaseImport.php index 9d8add6b8f..d9e491345c 100644 --- a/app/Actions/Database/StartDatabaseImport.php +++ b/app/Actions/Database/StartDatabaseImport.php @@ -318,7 +318,7 @@ class StartDatabaseImport private function setMinioClientAlias(S3Storage $storage, Server $server, string $helper): void { $remoteCommand = ($server->isNonRoot() ? 'sudo ' : '').'docker exec -i '.escapeshellarg($helper) - .' mc alias set s3temp '.escapeshellarg((string) $storage->endpoint).' >/dev/null'; + .' mc alias set'.$storage->minioClientPathOption().' s3temp '.escapeshellarg((string) $storage->endpoint).' >/dev/null'; $timeout = (int) config('constants.ssh.command_timeout'); SshRetryHandler::retry( diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index 3070e88ac6..7c954d9501 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -13,6 +13,7 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Notifications\Container\ContainerRestarted; +use Illuminate\Support\Facades\Cache; use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Decorators\JobDecorator; use Symfony\Component\Yaml\Yaml; @@ -21,6 +22,27 @@ class StartDatabaseProxy { use AsAction; + /** + * Version of the generated proxy configuration. Raise it when the configuration changes, so the + * status checks recreate proxies that an older Coolify version started with the old configuration. + */ + public const CONFIG_VERSION = '2'; + + public const CONFIG_VERSION_LABEL = 'coolify.databaseProxyConfigVersion'; + + /** + * True when a running proxy has an older configuration and no recreation was claimed in the last + * ten minutes. The claim stops a busy queue from recreating the proxy again before the first run. + */ + public static function claimOutdatedRefresh(string $databaseUuid, mixed $labels): bool + { + if ((string) collect($labels)->get(self::CONFIG_VERSION_LABEL) === self::CONFIG_VERSION) { + return false; + } + + return Cache::add("database-proxy-refresh:{$databaseUuid}", true, now()->addMinutes(10)); + } + public function configureJob(JobDecorator $job): void { $job->onQueue(deployment_queue()); @@ -85,6 +107,9 @@ class StartDatabaseProxy 'image' => 'nginx:stable-alpine', 'container_name' => $proxyContainerName, 'restart' => RESTART_MODE, + 'labels' => [ + self::CONFIG_VERSION_LABEL => self::CONFIG_VERSION, + ], 'ports' => [ "$database->public_port:$database->public_port", ], @@ -120,7 +145,6 @@ class StartDatabaseProxy ]; $dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2)); $nginxconf_base64 = base64_encode($nginxconf); - instant_remote_process(["docker rm -f $proxyContainerName"], $server, false); try { instant_remote_process([ @@ -129,6 +153,8 @@ class StartDatabaseProxy "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", // Pass -f so Compose uses only the file that Coolify wrote. "docker compose --project-directory {$configuration_dir} -f {$configuration_dir}/docker-compose.yaml pull", + // Remove the old proxy only after the pull, so a failed pull keeps it running. + dockerRemoveCommand($proxyContainerName), "docker compose --project-directory {$configuration_dir} -f {$configuration_dir}/docker-compose.yaml up -d", ], $server); } catch (\RuntimeException $e) { diff --git a/app/Actions/Development/StartDevelopmentQemuVm.php b/app/Actions/Development/StartDevelopmentQemuVm.php index 8e52a6dcc4..11cd1a369c 100644 --- a/app/Actions/Development/StartDevelopmentQemuVm.php +++ b/app/Actions/Development/StartDevelopmentQemuVm.php @@ -155,10 +155,18 @@ class StartDevelopmentQemuVm /** * nvidia-persistenced keeps the GPU open, which blocks libvirt from moving it to vfio-pci. + * Other users of the GPU (for example a desktop session) must stop first: if libvirt unbinds the + * nvidia driver while they hold the GPU open, the GPU stays unusable until the host reboots. */ private function releaseHostGpu(): void { Process::run('systemctl stop nvidia-persistenced'); + + $users = Process::run('fuser -v /dev/nvidia[0-9]* 2>&1'); + + if ($users->successful() && trim($users->output()) !== '') { + throw new RuntimeException("These host processes still use the NVIDIA GPU, so it cannot move to the VM. Stop them (for example the display manager), or remove the GPU from the VM with --fresh and without --gpu:\n".trim($users->output())); + } } private function destroyVm(string $domain): void diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index 5ce2a90f6e..c59545a9dd 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -241,6 +241,8 @@ class GetContainersStatus implements ShouldBeUnique })->first(); if (! $foundTcpProxy) { StartDatabaseProxy::run($service_db); + } elseif (! $this->server->isSwarm() && StartDatabaseProxy::claimOutdatedRefresh($proxyUuid, data_get($foundTcpProxy, 'Config.Labels'))) { + StartDatabaseProxy::run($service_db); } } else { // Clean up orphaned proxy when is_public=false @@ -292,6 +294,8 @@ class GetContainersStatus implements ShouldBeUnique })->first(); if (! $foundTcpProxy) { StartDatabaseProxy::run($database); + } elseif (! $this->server->isSwarm() && StartDatabaseProxy::claimOutdatedRefresh($uuid, data_get($foundTcpProxy, 'Config.Labels'))) { + StartDatabaseProxy::run($database); } } else { // Clean up orphaned proxy when is_public=false @@ -529,7 +533,9 @@ class GetContainersStatus implements ShouldBeUnique ->where('restart_limit_reached', false) ->update(['restart_limit_reached' => true]) === 1; - if ($restartLimitClaimed) { + // Stop again while a container still runs: an earlier stop can fail or come too late. + $stillRunning = $containerStatuses->contains(fn (string $status): bool => ! str($status)->contains('exited')); + if ($restartLimitClaimed || $stillRunning) { $application->refresh(); StopApplication::dispatch( application: $application, @@ -538,6 +544,8 @@ class GetContainersStatus implements ShouldBeUnique resetRestartCount: false, removeContainers: false, ); + } + if ($restartLimitClaimed) { $application->environment->project->team?->notify(new ApplicationRestartLimitReached($application)); } } diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php index 93774a0be2..57068e0b52 100644 --- a/app/Actions/Server/StartLogDrain.php +++ b/app/Actions/Server/StartLogDrain.php @@ -3,8 +3,10 @@ namespace App\Actions\Server; use App\Models\Server; +use App\Models\ServerSetting; use App\Models\Service; use App\Rules\ValidFluentBitConfig; +use Illuminate\Support\Facades\Cache; use Lorisleiva\Actions\Concerns\AsAction; class StartLogDrain @@ -13,12 +15,31 @@ class StartLogDrain public string $jobQueue = 'high'; + /** + * Bump when the generated files change (image, config, compose, or README), so that the + * server checks start running log drains again with the new files. + */ + public const CONFIG_VERSION = '1'; + + public const CONFIG_VERSION_LABEL = 'coolify.logDrainConfigVersion'; + + /** + * Whether a running log drain container was made with older files. Claims the refresh for + * 10 minutes, so that the frequent server checks queue only one restart. + */ + public static function claimOutdatedRefresh(Server $server, mixed $labels): bool + { + if ((string) collect($labels)->get(self::CONFIG_VERSION_LABEL) === self::CONFIG_VERSION) { + return false; + } + + return Cache::add("log-drain-refresh:{$server->uuid}", true, now()->addMinutes(10)); + } + public function handle(Server $server) { if ($server->settings->is_logdrain_newrelic_enabled) { $type = 'newrelic'; - } elseif ($server->settings->is_logdrain_highlight_enabled) { - $type = 'highlight'; } elseif ($server->settings->is_logdrain_axiom_enabled) { $type = 'axiom'; } elseif ($server->settings->is_logdrain_custom_enabled) { @@ -34,9 +55,6 @@ class StartLogDrain return 'Amazon CloudWatch Logs uses the built-in Docker awslogs driver.'; } - if ($type !== 'none') { - StopLogDrain::run($server); - } $serverName = $this->fluentBitValue((string) $server->name); try { if ($type === 'none') { @@ -49,8 +67,7 @@ class StartLogDrain [SERVICE] Flush 5 Daemon off - Tag container_logs - Log_Level debug + Log_Level warn Parsers_File parsers.conf [INPUT] Name forward @@ -76,27 +93,6 @@ class StartLogDrain # https://log-api.newrelic.com/log/v1 - US base_uri \${BASE_URI} "); - } elseif ($type === 'highlight') { - if (! $server->settings->is_logdrain_highlight_enabled) { - throw new \Exception('Highlight log drain is not enabled.'); - } - $config = base64_encode(' -[SERVICE] - Flush 5 - Daemon off - Log_Level debug - Parsers_File parsers.conf -[INPUT] - Name forward - tag ${HIGHLIGHT_PROJECT_ID} - Buffer_Chunk_Size 1M - Buffer_Max_Size 6M -[OUTPUT] - Name forward - Match * - Host otel.highlight.io - Port 24224 -'); } elseif ($type === 'axiom') { if (! $server->settings->is_logdrain_axiom_enabled) { throw new \Exception('Axiom log drain is not enabled.'); @@ -105,7 +101,7 @@ class StartLogDrain [SERVICE] Flush 5 Daemon off - Log_Level debug + Log_Level warn Parsers_File parsers.conf [INPUT] Name forward @@ -126,9 +122,9 @@ class StartLogDrain [OUTPUT] Name http Match * - Host api.axiom.co + Host \${AXIOM_DOMAIN} Port 443 - URI /v1/datasets/\${AXIOM_DATASET_NAME}/ingest + URI /v1/ingest/\${AXIOM_DATASET_NAME} # Authorization Bearer should be an API token Header Authorization Bearer \${AXIOM_API_KEY} compress gzip @@ -165,7 +161,7 @@ class StartLogDrain $compose = base64_encode(' services: coolify-log-drain: - image: cr.fluentbit.io/fluent/fluent-bit:2.0 + image: cr.fluentbit.io/fluent/fluent-bit:5.1 container_name: coolify-log-drain command: -c /fluent-bit.conf env_file: @@ -177,16 +173,22 @@ services: - 127.0.0.1:24224:24224 labels: - coolify.managed=true + - '.self::CONFIG_VERSION_LABEL.'='.self::CONFIG_VERSION.' restart: unless-stopped '); - $readme = base64_encode('# New Relic Log Drain -This log drain is based on [Fluent Bit](https://fluentbit.io/) and New Relic Log Forwarder. + $providerName = match ($type) { + 'newrelic' => 'New Relic', + 'axiom' => 'Axiom', + 'custom' => 'Custom', + }; + $readme = base64_encode("# {$providerName} Log Drain +This log drain is based on [Fluent Bit](https://fluentbit.io/). Files: - `fluent-bit.conf` - configuration file for Fluent Bit - `docker-compose.yml` - docker-compose file to run Fluent Bit - `.env` - environment variables for Fluent Bit -'); +"); $license_key = $server->settings->logdrain_newrelic_license_key; $base_uri = $server->settings->logdrain_newrelic_base_uri; $base_path = config('constants.coolify.base_config_path'); @@ -198,10 +200,9 @@ Files: $readme_path = $config_path.'/README.md'; if ($type === 'newrelic') { $envContent = "LICENSE_KEY={$license_key}\nBASE_URI={$base_uri}\n"; - } elseif ($type === 'highlight') { - $envContent = "HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id}\n"; } elseif ($type === 'axiom') { - $envContent = "AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$server->settings->logdrain_axiom_api_key}\n"; + $axiomDomain = trim((string) $server->settings->logdrain_axiom_domain) ?: ServerSetting::DEFAULT_AXIOM_DOMAIN; + $envContent = "AXIOM_DOMAIN={$axiomDomain}\nAXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$server->settings->logdrain_axiom_api_key}\n"; } elseif ($type === 'custom') { $envContent = ''; } else { @@ -209,6 +210,8 @@ Files: } $envEncoded = base64_encode($envContent); + // The running drain is removed only after the checks above and the image pull, so that an + // invalid custom config or a slow download does not stop log collection. $command = [ "echo 'Saving configuration'", "mkdir -p $config_path", @@ -217,7 +220,10 @@ Files: "echo '{$compose}' | base64 -d | tee $compose_path > /dev/null", "echo '{$readme}' | base64 -d | tee $readme_path > /dev/null", "echo '{$envEncoded}' | base64 -d | tee $config_path/.env > /dev/null", + "echo 'Pulling Fluent Bit'", + "docker compose -f $compose_path pull --quiet --ignore-pull-failures", "echo 'Starting Fluent Bit'", + 'docker rm -f coolify-log-drain >/dev/null 2>&1 || true', "docker compose -f $compose_path up -d", ]; $command = array_merge($command, $this->logDrainNetworkConnectCommands($server)); diff --git a/app/Actions/Server/ValidateServer.php b/app/Actions/Server/ValidateServer.php index f4855b9ffa..57be1a5a60 100644 --- a/app/Actions/Server/ValidateServer.php +++ b/app/Actions/Server/ValidateServer.php @@ -37,6 +37,19 @@ class ValidateServer $server->update([ 'validation_logs' => null, ]); + if ($server->hetzner_server_id) { + $status = $server->refreshHetznerState(); + if (in_array($status, ['initializing', 'starting', 'stopping', 'off', 'deleting', 'rebuilding', 'migrating'], true)) { + $this->error = $status === 'off' + ? 'Hetzner server is off. Power it on before validating.' + : "Hetzner server is {$status}. Wait until it is running, then validate."; + $server->update([ + 'validation_logs' => $this->error, + ]); + throw new \Exception($this->error); + } + } + if ($server->vultr_instance_id) { $status = $server->refreshVultrState(); if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) { diff --git a/app/Actions/Service/DeployServiceApplication.php b/app/Actions/Service/DeployServiceApplication.php index cc49010db6..abf66941a6 100644 --- a/app/Actions/Service/DeployServiceApplication.php +++ b/app/Actions/Service/DeployServiceApplication.php @@ -20,6 +20,7 @@ class DeployServiceApplication $composeServiceName = $serviceApplication->name; $service->parse(); + StartService::queueFileStorageSync($service); $service->saveComposeConfigs(); $service->isConfigurationChanged(save: true); diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index c7119bfefe..c9f0d6e079 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -3,6 +3,7 @@ namespace App\Actions\Service; use App\Actions\Shared\EnsureContentFilesOnServer; +use App\Jobs\ServerFilesFromServerJob; use App\Models\LocalFileVolume; use App\Models\Service; use App\Models\ServiceApplication; @@ -24,6 +25,7 @@ class StartService public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) { $service->parse(); + self::queueFileStorageSync($service); // Fetch remote secrets before stopping: if the secret manager fails, the service keeps running. // saveComposeConfigs() below reuses the fetched secrets. $service->ensureRemoteSecretsResolvable($service->environment_variables()->get()); @@ -103,6 +105,18 @@ class StartService return "{$connectedNetworks} | grep -qxF {$network} || docker network connect --alias {$containerName} {$network} {$containerName} || echo {$warning}"; } + /** + * Queues one sync of the file storages with the server for each resource of the service that has + * file storages. The sync marks each storage as a file or a directory, as it is on the server. + */ + public static function queueFileStorageSync(Service $service): void + { + $service->applications()->get() + ->concat($service->databases()->get()) + ->filter(fn (ServiceApplication|ServiceDatabase $resource): bool => $resource->fileStorages()->exists()) + ->each(fn (ServiceApplication|ServiceDatabase $resource) => ServerFilesFromServerJob::dispatch($resource)); + } + /** * Shows the volume warnings of the last parse (for example an external volume that the * service does not use yet) in the start log. diff --git a/app/Actions/User/DeleteUserTeams.php b/app/Actions/User/DeleteUserTeams.php index b2b06e7ba3..6c38942257 100644 --- a/app/Actions/User/DeleteUserTeams.php +++ b/app/Actions/User/DeleteUserTeams.php @@ -137,7 +137,6 @@ class DeleteUserTeams // Update the new owner's role to owner $team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']); - RevokeUserTeamTokens::forUserTeam($newOwner, $team->id); // Remove the current user from the team $team->members()->detach($this->user->id); diff --git a/app/Auth/Oidc/OidcDiscoveryService.php b/app/Auth/Oidc/OidcDiscoveryService.php index 0847afc9a7..eddddb219e 100644 --- a/app/Auth/Oidc/OidcDiscoveryService.php +++ b/app/Auth/Oidc/OidcDiscoveryService.php @@ -21,7 +21,7 @@ class OidcDiscoveryService $url = $issuerUrl.'/.well-known/openid-configuration'; try { - $response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($url); + $response = Http::timeout(15)->acceptJson()->get($url); } catch (Throwable $e) { throw new OidcDiscoveryException("Failed to fetch discovery document: {$e->getMessage()}", previous: $e); } @@ -68,7 +68,7 @@ class OidcDiscoveryService return Cache::remember($cacheKey, 21600, function () use ($jwksUri): array { try { - $response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($jwksUri); + $response = Http::timeout(15)->acceptJson()->get($jwksUri); } catch (Throwable $e) { throw new OidcJwksException("Failed to fetch JWKS: {$e->getMessage()}", previous: $e); } diff --git a/app/Exceptions/CloudProviderApiException.php b/app/Exceptions/CloudProviderApiException.php new file mode 100644 index 0000000000..287d4aafd9 --- /dev/null +++ b/app/Exceptions/CloudProviderApiException.php @@ -0,0 +1,73 @@ + */ + public readonly array $invalidFields; + + /** + * The error code and field names come from the provider, so only values that look like identifiers are kept. + * + * @param array $invalidFields + */ + public function __construct( + public readonly string $provider, + string $providerMessage, + public readonly int $status, + mixed $errorCode = null, + array $invalidFields = [], + ) { + parent::__construct("{$provider} API error: {$providerMessage}", $status); + + $errorCode = is_string($errorCode) ? strtolower($errorCode) : null; + $this->errorCode = $errorCode !== null && preg_match('/^[a-z_]{1,64}$/', $errorCode) ? $errorCode : null; + $this->invalidFields = array_values(array_filter( + $invalidFields, + fn ($name) => is_string($name) && preg_match('/^[a-z0-9_.\[\]]{1,64}$/i', $name) + )); + } + + /** + * A message that is safe to return to API clients. It is built only from the + * HTTP status, the error code, and field names, never from the provider's free-text message. + */ + public function safeMessage(): string + { + return match (true) { + $this->errorCode === 'resource_limit_exceeded' => "Your {$this->provider} account has reached a resource limit, for example the server limit. Remove unused resources or ask {$this->provider} for a higher limit.", + $this->errorCode === 'resource_unavailable' => 'The selected server type, image, or location is not available now. Select a different location or server type.', + $this->errorCode === 'uniqueness_error' => "A resource with the same name already exists in your {$this->provider} account, for example a server or an SSH key. Use a different name.", + $this->errorCode === 'maintenance', $this->status === 423, $this->status >= 500 => "{$this->provider} cannot process the request now. Try again later.", + $this->status === 401 => "The {$this->provider} API token is invalid or expired. Update the cloud provider token in Coolify.", + $this->status === 403 => "The {$this->provider} API token does not have permission for this request. Use a token with read and write access.", + $this->status === 404 => "{$this->provider} could not find a requested resource, for example the location, server type, image, or SSH key.", + $this->status === 409 => "{$this->provider} rejected the request because of a conflict with an existing resource.", + $this->status === 422 => "{$this->provider} rejected the request as invalid. Check the selected values and your account limits.".($this->invalidFields !== [] ? ' Invalid fields: '.implode(', ', $this->invalidFields).'.' : ''), + default => "{$this->provider} returned HTTP {$this->status}".($this->errorCode !== null ? " (error code: {$this->errorCode})" : '').'.', + }; + } + + /** + * The API response for a failed provider request: 502, because the upstream API rejected it. + */ + public function toJsonResponse(string $failureMessage): JsonResponse + { + return response()->json([ + 'message' => $failureMessage.' '.$this->safeMessage(), + 'provider_status' => $this->status, + 'provider_error_code' => $this->errorCode, + ], 502); + } +} diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index cadd543f39..b96dabadb9 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -381,7 +381,7 @@ class ApplicationsController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack'], + required: ['project_uuid', 'server_uuid', 'git_repository', 'git_branch', 'build_pack'], properties: [ 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], @@ -593,7 +593,7 @@ class ApplicationsController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack'], + required: ['project_uuid', 'server_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack'], properties: [ 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], @@ -803,7 +803,7 @@ class ApplicationsController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack'], + required: ['project_uuid', 'server_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack'], properties: [ 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], @@ -1013,7 +1013,7 @@ class ApplicationsController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'dockerfile'], + required: ['project_uuid', 'server_uuid', 'dockerfile'], properties: [ 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], @@ -1192,7 +1192,7 @@ class ApplicationsController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name'], + required: ['project_uuid', 'server_uuid', 'docker_registry_image_name'], properties: [ 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], @@ -2417,7 +2417,7 @@ class ApplicationsController extends Controller ]))->setStatusCode(201); } elseif ($type === 'dockerimage') { $validationRules = [ - 'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat], + 'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat(allowVariableTag: true)], 'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(), 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable', ]; @@ -2662,7 +2662,7 @@ class ApplicationsController extends Controller new OA\Parameter( name: 'service_name', in: 'query', - description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no running container matches.', + description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no container matches. Stopped containers are included.', required: false, schema: new OA\Schema(type: 'string'), ), @@ -2741,7 +2741,7 @@ class ApplicationsController extends Controller new OA\Parameter( name: 'service_name', in: 'query', - description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no running container matches.', + description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no container matches. Stopped containers are included.', required: false, schema: new OA\Schema(type: 'string'), ), @@ -2808,20 +2808,13 @@ class ApplicationsController extends Controller if (! $matchingContainer) { return response()->json([ - 'message' => "No running container found for service_name '{$serviceName}'.", + 'message' => "No container found for service_name '{$serviceName}'.", ], 404); } $container = $matchingContainer; } else { - $container = $containers->first(); - } - - $status = getContainerStatus($application->destination->server, $container['Names']); - if ($status !== 'running') { - return response()->json([ - 'message' => 'Application is not running.', - ], 400); + $container = $containers->firstWhere('State', 'running') ?? $containers->first(); } $lines = normalizeLogLines($request->query('lines')); diff --git a/app/Http/Controllers/Api/Concerns/RequiresDeployAbility.php b/app/Http/Controllers/Api/Concerns/RequiresDeployAbility.php index 28ccd598ed..9169c31de7 100644 --- a/app/Http/Controllers/Api/Concerns/RequiresDeployAbility.php +++ b/app/Http/Controllers/Api/Concerns/RequiresDeployAbility.php @@ -17,7 +17,7 @@ trait RequiresDeployAbility */ private function deployAbilityForbiddenResponse(Request $request, string $reason): ?JsonResponse { - if ($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root')) { + if ($this->hasDeployAbility($request)) { return null; } @@ -25,4 +25,9 @@ trait RequiresDeployAbility 'message' => "Missing required permissions: deploy. {$reason}", ], 403); } + + private function hasDeployAbility(Request $request): bool + { + return $request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'); + } } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index b8cf88e94f..39ef203226 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -352,8 +352,8 @@ class DatabasesController extends Controller response: 200, description: 'Get all databases', content: new OA\JsonContent( - type: 'string', - example: 'Content is very complex. Will be implemented later.', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Database'), ), ), new OA\Response( @@ -511,11 +511,8 @@ class DatabasesController extends Controller responses: [ new OA\Response( response: 200, - description: 'Get all databases', - content: new OA\JsonContent( - type: 'string', - example: 'Content is very complex. Will be implemented later.', - ), + description: 'Get database by UUID', + content: new OA\JsonContent(ref: '#/components/schemas/Database'), ), new OA\Response( response: 401, @@ -1550,7 +1547,7 @@ class DatabasesController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'], + required: ['server_uuid', 'project_uuid'], properties: [ 'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'], 'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'], @@ -1623,7 +1620,7 @@ class DatabasesController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'], + required: ['server_uuid', 'project_uuid'], properties: [ 'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'], 'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'], @@ -1692,7 +1689,7 @@ class DatabasesController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'], + required: ['server_uuid', 'project_uuid'], properties: [ 'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'], 'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'], @@ -1760,7 +1757,7 @@ class DatabasesController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'], + required: ['server_uuid', 'project_uuid'], properties: [ 'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'], 'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'], @@ -1829,7 +1826,7 @@ class DatabasesController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'], + required: ['server_uuid', 'project_uuid'], properties: [ 'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'], 'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'], @@ -1898,7 +1895,7 @@ class DatabasesController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'], + required: ['server_uuid', 'project_uuid'], properties: [ 'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'], 'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'], @@ -1970,7 +1967,7 @@ class DatabasesController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'], + required: ['server_uuid', 'project_uuid'], properties: [ 'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'], 'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'], @@ -2042,7 +2039,7 @@ class DatabasesController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'], + required: ['server_uuid', 'project_uuid'], properties: [ 'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'], 'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'], @@ -2111,7 +2108,7 @@ class DatabasesController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'], + required: ['server_uuid', 'project_uuid'], properties: [ 'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'], 'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'], @@ -2900,13 +2897,6 @@ class DatabasesController extends Controller $container = $containers->first(); - $status = getContainerStatus($database->destination->server, $container['Names']); - if ($status !== 'running') { - return response()->json([ - 'message' => 'Database is not running.', - ], 400); - } - $lines = normalizeLogLines($request->query('lines')); $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps')); $logs = sanitizeLogsForExport( @@ -4150,6 +4140,7 @@ class DatabasesController extends Controller $updatedEnvs = collect(); foreach ($bulk_data as $item) { + $item = Arr::only($item, ['key', 'value', 'is_literal', 'is_multiline', 'is_shown_once', 'comment']); if (array_key_exists('key', $item)) { $item['key'] = ValidationPatterns::normalizeEnvironmentVariableKey((string) $item['key']); } @@ -5292,7 +5283,7 @@ class DatabasesController extends Controller $uuid = new_public_id(); $name = $request->filled('name') ? $request->string('name')->toString() - : $database->name.'-clone-'.$uuid; + : clone_resource_name($database->name, $uuid); $cloneVolumeData = $request->boolean('clone_volumes', false); $newDatabase = $database->replicate([ diff --git a/app/Http/Controllers/Api/DigitalOceanController.php b/app/Http/Controllers/Api/DigitalOceanController.php index 427a805278..4da5c3509c 100644 --- a/app/Http/Controllers/Api/DigitalOceanController.php +++ b/app/Http/Controllers/Api/DigitalOceanController.php @@ -2,11 +2,12 @@ namespace App\Http\Controllers\Api; -use App\Actions\Server\ValidateServer; use App\Enums\ProxyTypes; +use App\Exceptions\CloudProviderApiException; use App\Exceptions\RateLimitException; use App\Http\Controllers\Api\Concerns\RequiresDeployAbility; use App\Http\Controllers\Controller; +use App\Jobs\ValidateNewCloudServerJob; use App\Models\CloudProviderToken; use App\Models\PrivateKey; use App\Models\Server; @@ -210,6 +211,7 @@ class DigitalOceanController extends Controller new OA\Response(response: 403, description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.'), new OA\Response(response: 422, description: 'Validation failed.'), new OA\Response(response: 429, description: 'DigitalOcean rate limit exceeded.'), + new OA\Response(response: 502, ref: '#/components/responses/502'), ] )] public function createServer(Request $request): JsonResponse @@ -285,7 +287,13 @@ class DigitalOceanController extends Controller $request->offsetSet('enable_ipv6', $request->boolean('enable_ipv6', true)); $request->offsetSet('monitoring', $request->boolean('monitoring', true)); $request->offsetSet('digitalocean_ssh_key_ids', $request->digitalocean_ssh_key_ids ?? []); - $request->offsetSet('instant_validate', $request->boolean('instant_validate', false)); + $request->offsetSet('instant_validate', $request->filled('instant_validate') ? $request->boolean('instant_validate') : $this->hasDeployAbility($request)); + if ($request->instant_validate) { + $forbidden = $this->deployAbilityForbiddenResponse($request, 'Instant validation installs Docker and starts the proxy on the new server, so it needs a token with the deploy permission.'); + if ($forbidden) { + return $forbidden; + } + } $token = $this->digitalOceanToken($request, $teamId); if ($token instanceof JsonResponse) { @@ -363,7 +371,7 @@ class DigitalOceanController extends Controller } if ($request->instant_validate) { - ValidateServer::dispatch($server); + ValidateNewCloudServerJob::dispatch($server); } auditLog('api.digitalocean_droplet.created', [ @@ -395,12 +403,13 @@ class DigitalOceanController extends Controller } return $response; - } catch (\Throwable $e) { + } catch (CloudProviderApiException $e) { $this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server); - logger()->error('Failed to create DigitalOcean server', [ - 'error' => $e->getMessage(), - ]); + return $e->toJsonResponse('Failed to create DigitalOcean server.'); + } catch (\Throwable $e) { + $this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server); + report($e); return response()->json(['message' => 'Failed to create DigitalOcean server.'], 500); } diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index ca0ec99116..7b1011a254 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -2,11 +2,12 @@ namespace App\Http\Controllers\Api; -use App\Actions\Server\ValidateServer; use App\Enums\ProxyTypes; +use App\Exceptions\CloudProviderApiException; use App\Exceptions\RateLimitException; use App\Http\Controllers\Api\Concerns\RequiresDeployAbility; use App\Http\Controllers\Controller; +use App\Jobs\ValidateNewCloudServerJob; use App\Models\CloudProviderToken; use App\Models\PrivateKey; use App\Models\Server; @@ -90,6 +91,7 @@ class HetznerController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response(response: 502, ref: '#/components/responses/502'), ] )] public function locations(Request $request) @@ -127,14 +129,18 @@ class HetznerController extends Controller $locations = $hetznerService->getLocations(); return response()->json($locations); + } catch (CloudProviderApiException $e) { + return $e->toJsonResponse('Failed to fetch Hetzner locations.'); } catch (\Throwable $e) { + report($e); + return response()->json(['message' => 'Failed to fetch Hetzner locations.'], 500); } } #[OA\Get( summary: 'Get Hetzner Server Types', - description: 'Get all available Hetzner server types (instance sizes).', + description: 'Get the Hetzner server types (instance sizes) that can be ordered now. Each type lists only the locations where it is in stock and not deprecated.', path: '/hetzner/server-types', operationId: 'get-hetzner-server-types', security: [ @@ -142,6 +148,13 @@ class HetznerController extends Controller ], tags: ['Hetzner'], parameters: [ + new OA\Parameter( + name: 'location', + in: 'query', + required: false, + description: 'Return only the server types that can be ordered in this location, for example nbg1.', + schema: new OA\Schema(type: 'string') + ), new OA\Parameter( name: 'cloud_provider_token_uuid', in: 'query', @@ -212,6 +225,7 @@ class HetznerController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response(response: 502, ref: '#/components/responses/502'), ] )] public function serverTypes(Request $request) @@ -224,6 +238,7 @@ class HetznerController extends Controller $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', + 'location' => 'nullable|string', ]); if ($validator->fails()) { @@ -246,10 +261,14 @@ class HetznerController extends Controller try { $hetznerService = new HetznerService($token->token); - $serverTypes = $hetznerService->getServerTypes(); + $serverTypes = $hetznerService->getServerTypes($request->query('location')); return response()->json($serverTypes); + } catch (CloudProviderApiException $e) { + return $e->toJsonResponse('Failed to fetch Hetzner server types.'); } catch (\Throwable $e) { + report($e); + return response()->json(['message' => 'Failed to fetch Hetzner server types.'], 500); } } @@ -312,6 +331,7 @@ class HetznerController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response(response: 502, ref: '#/components/responses/502'), ] )] public function images(Request $request) @@ -362,7 +382,11 @@ class HetznerController extends Controller }); return response()->json(array_values($filtered)); + } catch (CloudProviderApiException $e) { + return $e->toJsonResponse('Failed to fetch Hetzner images.'); } catch (\Throwable $e) { + report($e); + return response()->json(['message' => 'Failed to fetch Hetzner images.'], 500); } } @@ -422,6 +446,7 @@ class HetznerController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response(response: 502, ref: '#/components/responses/502'), ] )] public function sshKeys(Request $request) @@ -459,7 +484,11 @@ class HetznerController extends Controller $sshKeys = $hetznerService->getSshKeys(); return response()->json($sshKeys); + } catch (CloudProviderApiException $e) { + return $e->toJsonResponse('Failed to fetch Hetzner SSH keys.'); } catch (\Throwable $e) { + report($e); + return response()->json(['message' => 'Failed to fetch Hetzner SSH keys.'], 500); } } @@ -517,6 +546,7 @@ class HetznerController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response(response: 502, ref: '#/components/responses/502'), ] )] public function firewalls(Request $request) @@ -553,7 +583,11 @@ class HetznerController extends Controller $hetznerService = new HetznerService($token->token); return response()->json($hetznerService->getFirewalls()); + } catch (CloudProviderApiException $e) { + return $e->toJsonResponse('Failed to fetch Hetzner firewalls.'); } catch (\Throwable $e) { + report($e); + return response()->json(['message' => 'Failed to fetch Hetzner firewalls.'], 500); } } @@ -612,6 +646,7 @@ class HetznerController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response(response: 502, ref: '#/components/responses/502'), ] )] public function networks(Request $request) @@ -648,7 +683,11 @@ class HetznerController extends Controller $hetznerService = new HetznerService($token->token); return response()->json($hetznerService->getNetworks()); + } catch (CloudProviderApiException $e) { + return $e->toJsonResponse('Failed to fetch Hetzner networks.'); } catch (\Throwable $e) { + report($e); + return response()->json(['message' => 'Failed to fetch Hetzner networks.'], 500); } } @@ -685,7 +724,7 @@ class HetznerController extends Controller 'hetzner_firewall_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner firewall IDs to apply during server creation'], 'hetzner_network_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner network IDs to attach during server creation'], 'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional). Needs a token with the deploy permission.'], - 'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'], + 'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate the server and install Docker when it is running. Defaults to true for tokens with the deploy permission, and needs that permission.'], ], ), ), @@ -728,6 +767,7 @@ class HetznerController extends Controller response: 429, ref: '#/components/responses/429', ), + new OA\Response(response: 502, ref: '#/components/responses/502'), ] )] public function createServer(Request $request) @@ -832,8 +872,12 @@ class HetznerController extends Controller if (is_null($request->hetzner_network_ids)) { $request->offsetSet('hetzner_network_ids', []); } - if (is_null($request->instant_validate)) { - $request->offsetSet('instant_validate', false); + $request->offsetSet('instant_validate', $request->filled('instant_validate') ? $request->boolean('instant_validate') : $this->hasDeployAbility($request)); + if ($request->instant_validate) { + $forbidden = $this->deployAbilityForbiddenResponse($request, 'Instant validation installs Docker and starts the proxy on the new server, so it needs a token with the deploy permission.'); + if ($forbidden) { + return $forbidden; + } } if (! $request->boolean('enable_ipv4') && ! $request->boolean('enable_ipv6')) { @@ -960,6 +1004,7 @@ class HetznerController extends Controller 'private_key_id' => $privateKey->id, 'cloud_provider_token_id' => $token->id, 'hetzner_server_id' => $hetznerServer['id'], + 'hetzner_server_status' => $hetznerServer['status'] ?? null, ]); $server->proxy->set('status', 'exited'); @@ -976,7 +1021,7 @@ class HetznerController extends Controller // Validate server if requested if ($request->instant_validate) { - ValidateServer::dispatch($server); + ValidateNewCloudServerJob::dispatch($server); } auditLog('api.hetzner_server.created', [ @@ -1012,7 +1057,18 @@ class HetznerController extends Controller } return $response; + } catch (CloudProviderApiException $e) { + return $e->toJsonResponse('Failed to create Hetzner server.'); } catch (\Throwable $e) { + report($e); + if (isset($hetznerService, $hetznerServer['id'])) { + try { + $hetznerService->deleteServer((int) $hetznerServer['id']); + } catch (\Throwable $cleanupError) { + report($cleanupError); + } + } + return response()->json(['message' => 'Failed to create Hetzner server.'], 500); } } diff --git a/app/Http/Controllers/Api/HostingerController.php b/app/Http/Controllers/Api/HostingerController.php index 34eb2e94bf..1e28c93383 100644 --- a/app/Http/Controllers/Api/HostingerController.php +++ b/app/Http/Controllers/Api/HostingerController.php @@ -2,11 +2,11 @@ namespace App\Http\Controllers\Api; -use App\Actions\Server\ValidateServer; use App\Enums\ProxyTypes; use App\Exceptions\RateLimitException; use App\Http\Controllers\Api\Concerns\RequiresDeployAbility; use App\Http\Controllers\Controller; +use App\Jobs\ValidateNewCloudServerJob; use App\Models\CloudProviderToken; use App\Models\PrivateKey; use App\Models\Server; @@ -193,6 +193,14 @@ class HostingerController extends Controller } } + $request->offsetSet('instant_validate', $request->filled('instant_validate') ? $request->boolean('instant_validate') : $this->hasDeployAbility($request)); + if ($request->instant_validate) { + $forbidden = $this->deployAbilityForbiddenResponse($request, 'Instant validation installs Docker and starts the proxy on the new server, so it needs a token with the deploy permission.'); + if ($forbidden) { + return $forbidden; + } + } + $team = Team::find($teamId); if (Team::serverLimitReached($team)) { return response()->json(['message' => 'Server limit reached for your subscription.'], 400); @@ -267,8 +275,8 @@ class HostingerController extends Controller $ipAddress = $server->fresh()->ip; - if ($request->boolean('instant_validate') && ! $server->hasPlaceholderIp()) { - ValidateServer::dispatch($server); + if ($request->instant_validate) { + ValidateNewCloudServerJob::dispatch($server); } auditLog('api.hostinger_virtual_machine.created', [ diff --git a/app/Http/Controllers/Api/OpenApi.php b/app/Http/Controllers/Api/OpenApi.php index 77741ea537..4af7e503bd 100644 --- a/app/Http/Controllers/Api/OpenApi.php +++ b/app/Http/Controllers/Api/OpenApi.php @@ -35,6 +35,103 @@ use OpenApi\Attributes as OA; new OA\Property(property: 'finished_at', type: 'string', format: 'date-time', nullable: true), ], ), + new OA\Schema( + schema: 'Database', + description: 'Standalone database. The fields that start with an engine name (for example postgres_ or redis_) are only returned for that engine. Passwords, connection URLs and init_scripts are only returned for tokens with the read:sensitive or root ability.', + type: 'object', + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'description', type: 'string', nullable: true), + new OA\Property(property: 'database_type', type: 'string', enum: ['standalone-postgresql', 'standalone-redis', 'standalone-mongodb', 'standalone-mysql', 'standalone-mariadb', 'standalone-keydb', 'standalone-dragonfly', 'standalone-clickhouse', 'standalone-sqlite']), + new OA\Property(property: 'image', type: 'string'), + new OA\Property(property: 'status', type: 'string', description: 'Container status and health, for example running:healthy or exited:unhealthy.'), + new OA\Property(property: 'server_status', type: 'boolean', description: 'True when the server of the database is reachable and usable.'), + new OA\Property(property: 'environment_id', type: 'integer'), + new OA\Property(property: 'environment', ref: '#/components/schemas/Environment', description: 'Only returned by GET /databases/{uuid}. Includes the project.'), + new OA\Property(property: 'destination_id', type: 'integer'), + new OA\Property(property: 'destination_type', type: 'string'), + new OA\Property( + property: 'destination', + description: 'Docker network destination of the database, with its server.', + type: 'object', + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'network', type: 'string'), + new OA\Property(property: 'server_id', type: 'integer'), + new OA\Property(property: 'server', ref: '#/components/schemas/Server'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + ], + ), + new OA\Property(property: 'is_public', type: 'boolean', description: 'Not returned for SQLite.'), + new OA\Property(property: 'public_port', type: 'integer', nullable: true, description: 'Not returned for SQLite.'), + new OA\Property(property: 'public_port_timeout', type: 'integer', nullable: true, description: 'Proxy timeout of the public port in seconds. Not returned for SQLite.'), + new OA\Property(property: 'ports_mappings', type: 'string', nullable: true, description: 'Comma-separated host:container port mappings. Not returned for SQLite.'), + new OA\Property(property: 'enable_ssl', type: 'boolean', description: 'Not returned for ClickHouse and SQLite.'), + new OA\Property(property: 'ssl_mode', type: 'string', nullable: true, description: 'PostgreSQL, MongoDB and MySQL only.'), + new OA\Property(property: 'internal_db_url', type: 'string', nullable: true, description: 'Connection URL inside the Docker network. Contains the password. Not returned for SQLite.'), + new OA\Property(property: 'external_db_url', type: 'string', nullable: true, description: 'Public connection URL, when the database is public. Contains the password. Not returned for SQLite.'), + new OA\Property(property: 'init_scripts', type: 'array', nullable: true, items: new OA\Items(type: 'object', properties: [new OA\Property(property: 'index', type: 'integer'), new OA\Property(property: 'filename', type: 'string'), new OA\Property(property: 'content', type: 'string')]), description: 'PostgreSQL only.'), + new OA\Property(property: 'custom_docker_run_options', type: 'string', nullable: true), + new OA\Property(property: 'is_log_drain_enabled', type: 'boolean'), + new OA\Property(property: 'is_include_timestamps', type: 'boolean', description: 'Not returned for MariaDB.'), + new OA\Property(property: 'include_coolify_default_variables', type: 'boolean'), + new OA\Property(property: 'redact_all_env_values_in_logs', type: 'boolean'), + new OA\Property(property: 'health_check_enabled', type: 'boolean'), + new OA\Property(property: 'health_check_interval', type: 'integer'), + new OA\Property(property: 'health_check_timeout', type: 'integer'), + new OA\Property(property: 'health_check_retries', type: 'integer'), + new OA\Property(property: 'health_check_start_period', type: 'integer'), + new OA\Property(property: 'limits_memory', type: 'string'), + new OA\Property(property: 'limits_memory_swap', type: 'string'), + new OA\Property(property: 'limits_memory_swappiness', type: 'integer'), + new OA\Property(property: 'limits_memory_reservation', type: 'string'), + new OA\Property(property: 'limits_cpus', type: 'string'), + new OA\Property(property: 'limits_cpuset', type: 'string', nullable: true), + new OA\Property(property: 'limits_cpu_shares', type: 'integer'), + new OA\Property(property: 'config_hash', type: 'string', nullable: true, description: 'Internal hash that Coolify uses to find configuration changes that need a restart. It changes only when the database starts, so do not use it to compare configurations.'), + new OA\Property(property: 'restart_count', type: 'integer'), + new OA\Property(property: 'last_restart_at', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'last_restart_type', type: 'string', nullable: true), + new OA\Property(property: 'last_online_at', type: 'string', nullable: true), + new OA\Property(property: 'started_at', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'backup_configs', type: 'array', items: new OA\Items(type: 'object'), description: 'Only returned by GET /databases. The scheduled backups of the database, as returned by GET /databases/{uuid}/backups.'), + new OA\Property(property: 'postgres_user', type: 'string'), + new OA\Property(property: 'postgres_password', type: 'string', format: 'password'), + new OA\Property(property: 'postgres_db', type: 'string'), + new OA\Property(property: 'postgres_initdb_args', type: 'string', nullable: true), + new OA\Property(property: 'postgres_host_auth_method', type: 'string', nullable: true), + new OA\Property(property: 'postgres_conf', type: 'string', nullable: true), + new OA\Property(property: 'redis_password', type: 'string', format: 'password'), + new OA\Property(property: 'redis_conf', type: 'string', nullable: true), + new OA\Property(property: 'mongo_initdb_root_username', type: 'string'), + new OA\Property(property: 'mongo_initdb_root_password', type: 'string', format: 'password'), + new OA\Property(property: 'mongo_initdb_database', type: 'string'), + new OA\Property(property: 'mongo_conf', type: 'string', nullable: true), + new OA\Property(property: 'mysql_user', type: 'string'), + new OA\Property(property: 'mysql_password', type: 'string', format: 'password'), + new OA\Property(property: 'mysql_root_password', type: 'string', format: 'password'), + new OA\Property(property: 'mysql_database', type: 'string'), + new OA\Property(property: 'mysql_conf', type: 'string', nullable: true), + new OA\Property(property: 'mariadb_user', type: 'string'), + new OA\Property(property: 'mariadb_password', type: 'string', format: 'password'), + new OA\Property(property: 'mariadb_root_password', type: 'string', format: 'password'), + new OA\Property(property: 'mariadb_database', type: 'string'), + new OA\Property(property: 'mariadb_conf', type: 'string', nullable: true), + new OA\Property(property: 'keydb_password', type: 'string', format: 'password'), + new OA\Property(property: 'keydb_conf', type: 'string', nullable: true), + new OA\Property(property: 'dragonfly_password', type: 'string', format: 'password'), + new OA\Property(property: 'clickhouse_admin_user', type: 'string'), + new OA\Property(property: 'clickhouse_admin_password', type: 'string', format: 'password'), + new OA\Property(property: 'clickhouse_db', type: 'string'), + new OA\Property(property: 'sqlite_databases', type: 'string', description: 'Comma-separated SQLite database file names.'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'deleted_at', type: 'string', format: 'date-time', nullable: true), + ], + ), ], responses: [ new OA\Response( @@ -101,6 +198,17 @@ use OpenApi\Attributes as OA; new OA\Property(property: 'message', type: 'string', example: 'Rate limit exceeded. Please try again later.'), ] )), + new OA\Response( + response: 502, + description: 'The cloud provider API rejected the request.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Failed to create Hetzner server. The Hetzner API token does not have permission for this request. Use a token with read and write access.'), + new OA\Property(property: 'provider_status', type: 'integer', description: 'The HTTP status from the cloud provider API.', example: 403), + new OA\Property(property: 'provider_error_code', type: 'string', nullable: true, description: 'The error code from the cloud provider API, if it has one.', example: 'forbidden'), + ] + )), ], )] class OpenApi diff --git a/app/Http/Controllers/Api/ResourcesController.php b/app/Http/Controllers/Api/ResourcesController.php index 53d542d44b..5b7923e19f 100644 --- a/app/Http/Controllers/Api/ResourcesController.php +++ b/app/Http/Controllers/Api/ResourcesController.php @@ -21,10 +21,23 @@ class ResourcesController extends Controller responses: [ new OA\Response( response: 200, - description: 'Get all resources', + description: 'Get all applications, services and standalone databases. The other fields of each resource are the fields of the Application, Service or Database schema.', content: new OA\JsonContent( - type: 'string', - example: 'Content is very complex. Will be implemented later.', + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'description', type: 'string', nullable: true), + new OA\Property(property: 'type', type: 'string', description: 'application, service, or standalone- for databases (for example standalone-postgresql).'), + new OA\Property(property: 'status', type: 'string', description: 'For example running:healthy or exited:unhealthy.'), + new OA\Property(property: 'environment_id', type: 'integer'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + ], + additionalProperties: true, + ), ), ), new OA\Response( diff --git a/app/Http/Controllers/Api/S3StoragesController.php b/app/Http/Controllers/Api/S3StoragesController.php index c2ad48c51d..3aefa4f297 100644 --- a/app/Http/Controllers/Api/S3StoragesController.php +++ b/app/Http/Controllers/Api/S3StoragesController.php @@ -97,6 +97,7 @@ class S3StoragesController extends Controller 'endpoint' => ['type' => 'string'], 'bucket' => ['type' => 'string'], 'region' => ['type' => 'string'], + 'use_path_style_endpoint' => ['type' => 'boolean'], 'is_usable' => ['type' => 'boolean'], 'team_id' => ['type' => 'integer'], 'created_at' => ['type' => 'string'], @@ -160,6 +161,7 @@ class S3StoragesController extends Controller 'endpoint' => ['type' => 'string'], 'bucket' => ['type' => 'string'], 'region' => ['type' => 'string'], + 'use_path_style_endpoint' => ['type' => 'boolean'], 'is_usable' => ['type' => 'boolean'], 'team_id' => ['type' => 'integer'], 'created_at' => ['type' => 'string'], @@ -220,6 +222,7 @@ class S3StoragesController extends Controller 'endpoint' => ['type' => 'string', 'example' => 'https://s3.us-east-1.amazonaws.com', 'description' => 'S3-compatible endpoint URL.'], 'bucket' => ['type' => 'string', 'example' => 'my-bucket', 'description' => 'S3 bucket name.'], 'region' => ['type' => 'string', 'example' => 'us-east-1', 'description' => 'S3 region.'], + 'use_path_style_endpoint' => ['type' => 'boolean', 'description' => 'Use path-style URLs (endpoint/bucket). Set to false for providers that only accept virtual-hosted-style URLs (bucket.endpoint). Default: false for Alibaba Cloud OSS, Tencent Cloud COS, and Volcengine TOS endpoints, true for all other endpoints.'], 'key' => ['type' => 'string', 'description' => 'Access key.'], 'secret' => ['type' => 'string', 'description' => 'Secret key.'], 'is_usable' => ['type' => 'boolean', 'description' => 'Whether the storage is marked usable.'], @@ -258,7 +261,7 @@ class S3StoragesController extends Controller )] public function store(Request $request) { - $allowedFields = ['name', 'description', 'endpoint', 'bucket', 'region', 'key', 'secret', 'is_usable']; + $allowedFields = ['name', 'description', 'endpoint', 'bucket', 'region', 'key', 'secret', 'use_path_style_endpoint', 'is_usable']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -281,6 +284,7 @@ class S3StoragesController extends Controller 'region' => 'required|string|max:255', 'key' => 'required|string|max:255', 'secret' => 'required|string|max:255', + 'use_path_style_endpoint' => 'sometimes|boolean', 'is_usable' => 'sometimes|boolean', ]); if ($validationError instanceof JsonResponse) { @@ -296,6 +300,7 @@ class S3StoragesController extends Controller 'region' => $body['region'], 'key' => $body['key'], 'secret' => $body['secret'], + 'use_path_style_endpoint' => $body['use_path_style_endpoint'] ?? S3Storage::detectPathStyleEndpoint($body['endpoint']), 'is_usable' => $body['is_usable'] ?? false, ]); @@ -335,6 +340,7 @@ class S3StoragesController extends Controller 'endpoint' => ['type' => 'string', 'description' => 'S3-compatible endpoint URL.'], 'bucket' => ['type' => 'string', 'description' => 'S3 bucket name.'], 'region' => ['type' => 'string', 'description' => 'S3 region.'], + 'use_path_style_endpoint' => ['type' => 'boolean', 'description' => 'Use path-style URLs (endpoint/bucket). Set to false for providers that only accept virtual-hosted-style URLs (bucket.endpoint), such as Alibaba Cloud OSS.'], 'key' => ['type' => 'string', 'description' => 'Access key.'], 'secret' => ['type' => 'string', 'description' => 'Secret key.'], 'is_usable' => ['type' => 'boolean', 'description' => 'Whether the storage is marked usable.'], @@ -373,7 +379,7 @@ class S3StoragesController extends Controller )] public function update(Request $request) { - $allowedFields = ['name', 'description', 'endpoint', 'bucket', 'region', 'key', 'secret', 'is_usable']; + $allowedFields = ['name', 'description', 'endpoint', 'bucket', 'region', 'key', 'secret', 'use_path_style_endpoint', 'is_usable']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -395,6 +401,7 @@ class S3StoragesController extends Controller 'region' => 'sometimes|string|max:255', 'key' => 'sometimes|string|max:255', 'secret' => 'sometimes|string|max:255', + 'use_path_style_endpoint' => 'sometimes|boolean', 'is_usable' => 'sometimes|boolean', ]); if ($validationError instanceof JsonResponse) { diff --git a/app/Http/Controllers/Api/ServerLogDrainsController.php b/app/Http/Controllers/Api/ServerLogDrainsController.php index a5da9edb89..bacd75aebe 100644 --- a/app/Http/Controllers/Api/ServerLogDrainsController.php +++ b/app/Http/Controllers/Api/ServerLogDrainsController.php @@ -7,6 +7,7 @@ use App\Actions\Server\StopLogDrain; use App\Http\Controllers\Controller; use App\Models\Server; use App\Rules\ValidFluentBitConfig; +use App\Rules\ValidHostname; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -20,6 +21,7 @@ class ServerLogDrainsController extends Controller 'is_logdrain_axiom_enabled', 'logdrain_axiom_dataset_name', 'logdrain_axiom_api_key', + 'logdrain_axiom_domain', 'is_logdrain_custom_enabled', 'logdrain_custom_config', 'logdrain_custom_config_parser', @@ -47,6 +49,7 @@ class ServerLogDrainsController extends Controller 'logdrain_newrelic_base_uri' => $settings->logdrain_newrelic_base_uri, 'is_logdrain_axiom_enabled' => (bool) $settings->is_logdrain_axiom_enabled, 'logdrain_axiom_dataset_name' => $settings->logdrain_axiom_dataset_name, + 'logdrain_axiom_domain' => $settings->logdrain_axiom_domain, 'is_logdrain_custom_enabled' => (bool) $settings->is_logdrain_custom_enabled, 'is_logdrain_cloudwatch_enabled' => (bool) $settings->is_logdrain_cloudwatch_enabled, 'logdrain_cloudwatch_region' => $settings->logdrain_cloudwatch_region, @@ -85,6 +88,7 @@ class ServerLogDrainsController extends Controller new OA\Property(property: 'logdrain_newrelic_base_uri', type: 'string', nullable: true), new OA\Property(property: 'is_logdrain_axiom_enabled', type: 'boolean'), new OA\Property(property: 'logdrain_axiom_dataset_name', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_axiom_domain', type: 'string', nullable: true), new OA\Property(property: 'logdrain_axiom_api_key', type: 'string', description: 'Only present with read:sensitive.'), new OA\Property(property: 'is_logdrain_custom_enabled', type: 'boolean'), new OA\Property(property: 'logdrain_custom_config', type: 'string', description: 'Only present with read:sensitive.'), @@ -138,6 +142,7 @@ class ServerLogDrainsController extends Controller new OA\Property(property: 'logdrain_newrelic_base_uri', type: 'string'), new OA\Property(property: 'is_logdrain_axiom_enabled', type: 'boolean'), new OA\Property(property: 'logdrain_axiom_dataset_name', type: 'string'), + new OA\Property(property: 'logdrain_axiom_domain', type: 'string', description: 'Base domain of the Axiom edge deployment. Default: us-east-1.aws.edge.axiom.co. Use eu-central-1.aws.edge.axiom.co for EU datasets.'), new OA\Property(property: 'logdrain_axiom_api_key', type: 'string'), new OA\Property(property: 'is_logdrain_custom_enabled', type: 'boolean'), new OA\Property(property: 'logdrain_custom_config', type: 'string'), @@ -185,6 +190,7 @@ class ServerLogDrainsController extends Controller 'is_logdrain_axiom_enabled' => 'boolean', 'logdrain_axiom_dataset_name' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], 'logdrain_axiom_api_key' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + 'logdrain_axiom_domain' => ['nullable', 'string', new ValidHostname], 'is_logdrain_custom_enabled' => 'boolean', 'logdrain_custom_config' => ['nullable', 'string', new ValidFluentBitConfig], 'logdrain_custom_config_parser' => ['nullable', 'string', new ValidFluentBitConfig(parsersOnly: true)], @@ -250,7 +256,7 @@ class ServerLogDrainsController extends Controller } if ($settings->is_logdrain_cloudwatch_enabled) { - if ($settings->is_logdrain_newrelic_enabled || $settings->is_logdrain_axiom_enabled || $settings->is_logdrain_custom_enabled || $settings->is_logdrain_highlight_enabled) { + if ($settings->is_logdrain_newrelic_enabled || $settings->is_logdrain_axiom_enabled || $settings->is_logdrain_custom_enabled) { return response()->json([ 'message' => 'Validation failed.', 'errors' => [ diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index f6147855aa..bffff06ff2 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -533,8 +533,7 @@ class ServiceApplicationsController extends Controller $containerName = $serviceApplication->name.'-'.$serviceApplication->service->uuid; - $status = getContainerStatus($server, $containerName); - if ($status !== 'running') { + if (! is_array(getContainerStatus($server, $containerName, all_data: true))) { return response()->json([ 'message' => 'Service application container is not running.', ], 400); diff --git a/app/Http/Controllers/Api/ServiceDatabasesController.php b/app/Http/Controllers/Api/ServiceDatabasesController.php index fb4ad6d892..ad666fa5bc 100644 --- a/app/Http/Controllers/Api/ServiceDatabasesController.php +++ b/app/Http/Controllers/Api/ServiceDatabasesController.php @@ -405,7 +405,7 @@ class ServiceDatabasesController extends Controller [$serviceDatabase, $server] = $resolved; $containerName = $serviceDatabase->name.'-'.$serviceDatabase->service->uuid; - if (getContainerStatus($server, $containerName) !== 'running') { + if (! is_array(getContainerStatus($server, $containerName, all_data: true))) { return response()->json(['message' => 'Service database container is not running.'], 400); } diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 0bb16d67ad..222baba002 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -22,6 +22,7 @@ use App\Support\ValidationPatterns; 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\Validator; use OpenApi\Attributes as OA; @@ -302,7 +303,7 @@ class ServicesController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'], + required: ['server_uuid', 'project_uuid'], properties: [ 'type' => ['description' => 'The one-click service type (e.g. "actualbudget", "calibre-web", "gitea-with-mysql" ...)', 'type' => 'string'], 'name' => ['type' => 'string', 'maxLength' => 255, 'description' => 'Name of the service.'], @@ -993,13 +994,6 @@ class ServicesController extends Controller return response()->json(['message' => 'Container not found.'], 404); } - $status = getContainerStatus($service->destination->server, $container['Names']); - if ($status !== 'running') { - return response()->json([ - 'message' => 'Container is not running.', - ], 400); - } - $lines = normalizeLogLines($request->query('lines')); $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps')); $logs = sanitizeLogsForExport( @@ -1702,6 +1696,7 @@ class ServicesController extends Controller $updatedEnvs = collect(); foreach ($bulk_data as $item) { + $item = Arr::only($item, ['key', 'value', 'is_literal', 'is_multiline', 'is_shown_once', 'comment']); if (array_key_exists('key', $item)) { $item['key'] = ValidationPatterns::normalizeEnvironmentVariableKey((string) $item['key']); } @@ -3345,7 +3340,7 @@ class ServicesController extends Controller $uuid = new_public_id(); $name = $request->filled('name') ? $request->string('name')->toString() - : $service->name.'-clone-'.$uuid; + : clone_resource_name($service->name, $uuid); $cloneVolumeData = $request->boolean('clone_volumes', false); $newService = clone_service($service, $destination, [ diff --git a/app/Http/Controllers/Api/VultrController.php b/app/Http/Controllers/Api/VultrController.php index 31aa2b4bac..e5c2664a96 100644 --- a/app/Http/Controllers/Api/VultrController.php +++ b/app/Http/Controllers/Api/VultrController.php @@ -2,11 +2,12 @@ namespace App\Http\Controllers\Api; -use App\Actions\Server\ValidateServer; use App\Enums\ProxyTypes; +use App\Exceptions\CloudProviderApiException; use App\Exceptions\RateLimitException; use App\Http\Controllers\Api\Concerns\RequiresDeployAbility; use App\Http\Controllers\Controller; +use App\Jobs\ValidateNewCloudServerJob; use App\Models\CloudProviderToken; use App\Models\PrivateKey; use App\Models\Server; @@ -193,6 +194,7 @@ class VultrController extends Controller new OA\Response(response: 403, description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.'), new OA\Response(response: 422, description: 'Validation failed.'), new OA\Response(response: 429, description: 'Vultr API rate limit exceeded.'), + new OA\Response(response: 502, ref: '#/components/responses/502'), ] )] public function createServer(Request $request): JsonResponse @@ -275,8 +277,12 @@ class VultrController extends Controller if (is_null($request->vultr_ssh_key_ids)) { $request->offsetSet('vultr_ssh_key_ids', []); } - if (is_null($request->instant_validate)) { - $request->offsetSet('instant_validate', false); + $request->offsetSet('instant_validate', $request->filled('instant_validate') ? $request->boolean('instant_validate') : $this->hasDeployAbility($request)); + if ($request->instant_validate) { + $forbidden = $this->deployAbilityForbiddenResponse($request, 'Instant validation installs Docker and starts the proxy on the new server, so it needs a token with the deploy permission.'); + if ($forbidden) { + return $forbidden; + } } if ($request->disable_public_ipv4 && ! $request->enable_ipv6) { @@ -371,7 +377,7 @@ class VultrController extends Controller } if ($request->instant_validate) { - ValidateServer::dispatch($server); + ValidateNewCloudServerJob::dispatch($server); } auditLog('api.vultr_server.created', [ @@ -403,12 +409,13 @@ class VultrController extends Controller } return $response; - } catch (\Throwable $e) { + } catch (CloudProviderApiException $e) { $this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server); - logger()->error('Failed to create Vultr server', [ - 'error' => $e->getMessage(), - ]); + return $e->toJsonResponse('Failed to create Vultr server.'); + } catch (\Throwable $e) { + $this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server); + report($e); return response()->json(['message' => 'Failed to create Vultr server.'], 500); } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index e644a68c26..c69487031d 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -735,7 +735,6 @@ class Github extends Controller $data = Http::GitSource($api_url)->withBody(null) ->accept('application/vnd.github+json') ->timeout(10) - ->connectTimeout(5) ->post("$api_url/app-manifests/$code/conversions") ->throw() ->json(); @@ -841,7 +840,6 @@ class Github extends Controller 'Accept' => 'application/vnd.github+json', ]) ->timeout(10) - ->connectTimeout(5) ->get("{$github_app->api_url}/app/installations/{$installation_id}"); } catch (\Throwable $e) { return strip_tags(str_replace('
', ' ', $e->getMessage())); diff --git a/app/Http/Middleware/ApiAllowed.php b/app/Http/Middleware/ApiAllowed.php index 541bf3919a..7d6600bbb1 100644 --- a/app/Http/Middleware/ApiAllowed.php +++ b/app/Http/Middleware/ApiAllowed.php @@ -18,7 +18,7 @@ class ApiAllowed $isMcpRestBridge = $request->attributes->get('mcp_rest_bridge') === true; if (! $isMcpRestBridge && instanceSettings()->is_api_enabled === false) { - return response()->json(['success' => true, 'message' => 'API is disabled.'], 403); + return response()->json(['success' => false, 'message' => 'API is disabled.'], 403); } return app(ApiIpAllowed::class)->handle($request, $next); diff --git a/app/Http/Middleware/ApiIpAllowed.php b/app/Http/Middleware/ApiIpAllowed.php index 72aaa4b7c8..b37a9b38a7 100644 --- a/app/Http/Middleware/ApiIpAllowed.php +++ b/app/Http/Middleware/ApiIpAllowed.php @@ -23,7 +23,7 @@ class ApiIpAllowed $allowedIps = array_filter(array_map('trim', explode(',', $allowedIps))); if (! empty($allowedIps) && ! checkIPAgainstAllowlist(client_ip($request), $allowedIps)) { - return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403); + return response()->json(['success' => false, 'message' => 'You are not allowed to access the API.'], 403); } return $next($request); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 96bc5a7f83..53b278767a 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -810,14 +810,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $fileStorage->is_directory = true; $fileStorage->content = null; $fileStorage->save(); - $fileStorage->deleteStorageOnServer($this->mainServer); - $fileStorage->saveStorageOnServer($this->mainServer); + $fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->deleteStorageOnServer($this->mainServer); + $fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->saveStorageOnServer($this->mainServer); } elseif ($fileStat->value() === 'regular file' && $fileStorage->is_directory) { $fileStorage->is_directory = false; $fileStorage->is_based_on_git = true; $fileStorage->save(); - $fileStorage->deleteStorageOnServer($this->mainServer); - $fileStorage->saveStorageOnServer($this->mainServer); + $fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->deleteStorageOnServer($this->mainServer); + $fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->saveStorageOnServer($this->mainServer); } } } @@ -849,12 +849,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $service; }); $composeFile['services'] = $services->toArray(); - if (empty($composeFile)) { - $this->application_deployment_queue->addLogEntry('Failed to parse docker-compose file.'); - $this->fail('Failed to parse docker-compose file.'); - - return; - } // Add build secrets to compose file if enabled and BuildKit is supported if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) { @@ -994,12 +988,25 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue /** * Parses the Compose file for this deployment and writes the volume warnings of the parser * (for example an external volume that the resource does not use yet) to the deployment log. + * A deployment of the application also queues one sync of the file storages with the server, which + * loads the content of the files that come from the repository. A preview uses its own copies of the + * files, so its deployment does not sync the storages of the application. + * The parser gives no services when it cannot parse the file. The deployment stops then, because + * an empty Compose file fails later with the unclear Docker error "no service selected". * * @return Collection + * + * @throws DeploymentException When the parsed Compose file has no services */ private function parseComposeFileForDeployment(): Collection { $composeFile = collect($this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'), commit: $this->commit)); + if (blank(data_get($composeFile, 'services'))) { + throw new DeploymentException("The Docker Compose file has no services, or Coolify could not parse it. Check that {$this->application->docker_compose_location} is valid YAML and has a services section."); + } + if ($this->pull_request_id === 0) { + ServerFilesFromServerJob::dispatch($this->application); + } foreach ($this->application->composeVolumeWarnings() as $warning) { $this->application_deployment_queue->addLogEntry("Warning: {$warning}", 'stderr'); } @@ -1158,6 +1165,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } + /** + * The pull request whose file storage paths this deployment writes. Only the current Compose parser + * mounts the `-pr-` copies in a preview; other build packs and the legacy parser mount the + * paths of the application. + */ + private function previewFileStoragePullRequestId(): int + { + return $this->application->build_pack === 'dockercompose' && (int) $this->application->compose_parsing_version >= 3 + ? $this->pull_request_id + : 0; + } + /** * The queued ServerStorageSaveJob writes new content files, but it can run after the containers * start. Preserve-repository deployments write all file storages in write_deployment_configurations(). @@ -1171,7 +1190,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } EnsureContentFilesOnServer::run( - $this->application->fileStorages()->get(), + $this->application->fileStorages()->get()->map->forPullRequest($this->previewFileStoragePullRequestId()), $this->mainServer, function (string $message, string $type): void { $this->application_deployment_queue->addLogEntry($message, $type); @@ -1334,7 +1353,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } foreach ($this->application->fileStorages as $fileStorage) { if (! $fileStorage->is_host_file && ! $fileStorage->is_based_on_git && ! $fileStorage->is_directory) { - $fileStorage->saveStorageOnServer($this->mainServer); + $fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->saveStorageOnServer($this->mainServer); } } if ($this->use_build_server) { @@ -3332,18 +3351,21 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return 'GIT_SSH_COMMAND="'.$sshCommand.'" '.$gitCommand.' ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef); } + private function update_repository_project_id(): void + { + $repositorySlug = gitRepositorySlug($this->customRepository); + $repository = githubApi($this->source, "repos/{$repositorySlug}"); + $repository_project_id = data_get($repository, 'data.id'); + if (isset($repository_project_id) && $this->application->repository_project_id !== $repository_project_id) { + $this->application->repository_project_id = $repository_project_id; + $this->application->save(); + } + } + private function check_git_if_build_needed() { if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) { - $repository = githubApi($this->source, "repos/{$this->customRepository}"); - $data = data_get($repository, 'data'); - $repository_project_id = data_get($data, 'id'); - if (isset($repository_project_id)) { - if (blank($this->application->repository_project_id) || $this->application->repository_project_id !== $repository_project_id) { - $this->application->repository_project_id = $repository_project_id; - $this->application->save(); - } - } + $this->update_repository_project_id(); } $this->resolve_bitbucket_pull_request_commit(); $this->generate_git_import_commands(); diff --git a/app/Jobs/ApplicationPullRequestUpdateJob.php b/app/Jobs/ApplicationPullRequestUpdateJob.php index 025daa12b0..8a7766123f 100755 --- a/app/Jobs/ApplicationPullRequestUpdateJob.php +++ b/app/Jobs/ApplicationPullRequestUpdateJob.php @@ -70,7 +70,7 @@ class ApplicationPullRequestUpdateJob implements ShouldBeEncrypted, ShouldQueue private function update_comment() { - ['data' => $data] = githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/issues/comments/{$this->preview->pull_request_issue_comment_id}", method: 'patch', data: [ + ['data' => $data] = githubApi(source: $this->application->source, endpoint: '/repos/'.gitRepositorySlug($this->application->git_repository)."/issues/comments/{$this->preview->pull_request_issue_comment_id}", method: 'patch', data: [ 'body' => $this->body, ], throwError: false); if (data_get($data, 'message') === 'Not Found') { @@ -80,7 +80,7 @@ class ApplicationPullRequestUpdateJob implements ShouldBeEncrypted, ShouldQueue private function create_comment() { - ['data' => $data] = githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/issues/{$this->preview->pull_request_id}/comments", method: 'post', data: [ + ['data' => $data] = githubApi(source: $this->application->source, endpoint: '/repos/'.gitRepositorySlug($this->application->git_repository)."/issues/{$this->preview->pull_request_id}/comments", method: 'post', data: [ 'body' => $this->body, ]); $this->preview->pull_request_issue_comment_id = $data['id']; @@ -89,7 +89,7 @@ class ApplicationPullRequestUpdateJob implements ShouldBeEncrypted, ShouldQueue private function delete_comment() { - githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/issues/comments/{$this->preview->pull_request_issue_comment_id}", method: 'delete'); + githubApi(source: $this->application->source, endpoint: '/repos/'.gitRepositorySlug($this->application->git_repository)."/issues/comments/{$this->preview->pull_request_issue_comment_id}", method: 'delete'); } private function getPreviewLinks(): string diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index a05d713165..07e2fd56e3 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -970,7 +970,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue private function setMinioClientAlias(Server $server, string $container, S3Storage $s3, string $resolveOptions): void { $remoteCommand = ($server->isNonRoot() ? 'sudo ' : '').'docker exec -i '.escapeshellarg($container) - .' mc alias set'.$resolveOptions.' temporary '.escapeshellarg($s3->endpoint).' >/dev/null'; + .' mc alias set'.$resolveOptions.$s3->minioClientPathOption().' temporary '.escapeshellarg($s3->endpoint).' >/dev/null'; $timeout = (int) config('constants.ssh.command_timeout'); SshRetryHandler::retry( diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 9a9fd8a56c..ea15d0a5f6 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -116,6 +116,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced public bool $foundLogDrainContainer = false; + public array $logDrainContainerLabels = []; + private ?array $cachedDestinationIds = null; public function middleware(): array @@ -275,6 +277,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $name = data_get($container, 'name'); if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) { $this->foundLogDrainContainer = true; + $this->logDrainContainerLabels = $labels->all(); } // Containers are matched by owner UUID; numeric ids differ between instances. if (isContainerOfType($labels, 'application')) { @@ -614,7 +617,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced ->where('restart_limit_reached', false) ->update(['restart_limit_reached' => true]) === 1; - if ($restartLimitClaimed) { + // Stop again while a container still runs: an earlier stop can fail or come too late. + $stillRunning = $containerStatuses->contains(fn (string $status): bool => ! str($status)->contains('exited')); + if ($restartLimitClaimed || $stillRunning) { $application->refresh(); StopApplication::dispatch( application: $application, @@ -623,6 +628,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced resetRestartCount: false, removeContainers: false, ); + } + if ($restartLimitClaimed) { $application->environment->project->team?->notify(new ApplicationRestartLimitReached($application)); } } @@ -860,6 +867,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced if (! $tcpProxyContainerFound) { StartDatabaseProxy::dispatch($database); $this->server->team?->notify(new ContainerRestarted("TCP Proxy for {$database->name}", $this->server, restartedResource: $database)); + } elseif (StartDatabaseProxy::claimOutdatedRefresh($databaseUuid, data_get($tcpProxyContainerFound, 'labels'))) { + StartDatabaseProxy::dispatch($database); } } elseif ($this->isRunning($containerStatus) && ! $tcpProxy) { // Clean up orphaned proxy containers when is_public=false @@ -973,7 +982,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced private function checkLogDrainContainer() { - if ($this->server->isFluentBitLogDrainEnabled() && $this->foundLogDrainContainer === false) { + if (! $this->server->isFluentBitLogDrainEnabled()) { + return; + } + if ($this->foundLogDrainContainer === false || StartLogDrain::claimOutdatedRefresh($this->server, $this->logDrainContainerLabels)) { StartLogDrain::dispatch($this->server); } } diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php index 1d449cc23e..2d00f8bbd7 100644 --- a/app/Jobs/ServerCheckJob.php +++ b/app/Jobs/ServerCheckJob.php @@ -113,7 +113,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue })->first(); if ($foundLogDrainContainer) { $status = data_get($foundLogDrainContainer, 'State.Status'); - if ($status !== 'running') { + if ($status !== 'running' || StartLogDrain::claimOutdatedRefresh($this->server, data_get($foundLogDrainContainer, 'Config.Labels'))) { StartLogDrain::dispatch($this->server); } } else { diff --git a/app/Jobs/ServerCloudProviderStatusCheckJob.php b/app/Jobs/ServerCloudProviderStatusCheckJob.php index 46d0898c93..696adfe19c 100644 --- a/app/Jobs/ServerCloudProviderStatusCheckJob.php +++ b/app/Jobs/ServerCloudProviderStatusCheckJob.php @@ -33,25 +33,7 @@ class ServerCloudProviderStatusCheckJob implements ShouldBeEncrypted, ShouldQueu public function handle(): void { try { - if (! $this->server->cloudProviderToken) { - return; - } - - match ($this->server->cloudProviderToken->provider) { - 'hetzner' => $this->server->hetzner_server_id - ? $this->server->refreshHetznerState() - : null, - 'vultr' => $this->server->vultr_instance_id - ? $this->server->refreshVultrState() - : null, - 'digitalocean' => $this->server->digitalocean_droplet_id - ? $this->server->refreshDigitalOceanState() - : null, - 'hostinger' => $this->server->hostinger_virtual_machine_id - ? $this->server->refreshHostingerState() - : null, - default => null, - }; + $this->server->refreshCloudProviderState(); } catch (\Throwable $e) { Log::debug('Cloud provider status check failed', [ 'server_id' => $this->server->id, diff --git a/app/Jobs/ServerFilesFromServerJob.php b/app/Jobs/ServerFilesFromServerJob.php index 73641131fe..3d0b06b2f7 100644 --- a/app/Jobs/ServerFilesFromServerJob.php +++ b/app/Jobs/ServerFilesFromServerJob.php @@ -18,8 +18,7 @@ class ServerFilesFromServerJob implements ShouldBeEncrypted, ShouldBeUniqueUntil use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; /** - * Only one sync per resource can wait in the queue. The compose parser dispatches - * this job once per volume on every parse, so without this the queue fills with copies. + * Only one sync per resource can wait in the queue, also when deployments follow each other quickly. */ public int $uniqueFor = 600; diff --git a/app/Jobs/ValidateNewCloudServerJob.php b/app/Jobs/ValidateNewCloudServerJob.php new file mode 100644 index 0000000000..e1283f3937 --- /dev/null +++ b/app/Jobs/ValidateNewCloudServerJob.php @@ -0,0 +1,75 @@ +onQueue('high'); + } + + public function handle(): void + { + if (! $this->server->canBeValidated() || $this->server->is_validating || $this->server->isFunctional()) { + return; + } + + if ($this->isReady()) { + ValidateAndInstallServerJob::dispatch($this->server); + + return; + } + + if ($this->attempt >= self::MAX_ATTEMPTS) { + $this->server->update([ + 'validation_logs' => 'The server did not start or accept SSH connections within '.(self::MAX_ATTEMPTS * self::RETRY_DELAY_SECONDS / 60).' minutes after it was created. Validate it manually.', + ]); + + return; + } + + self::dispatch($this->server, $this->attempt + 1)->delay(now()->addSeconds(self::RETRY_DELAY_SECONDS)); + } + + private function isReady(): bool + { + try { + $status = $this->server->refreshCloudProviderState(); + } catch (\Throwable) { + return false; + } + + if (! in_array($status, ['running', 'active'], true) || $this->server->hasPlaceholderIp()) { + return false; + } + + return $this->server->validateConnection()['uptime']; + } +} diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index 344cefdccc..b7524c7012 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -487,7 +487,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue private function setMinioClientAlias(Server $server, string $container, S3Storage $s3, string $resolveOptions): void { $remoteCommand = ($server->isNonRoot() ? 'sudo ' : '').'docker exec -i '.escapeshellarg($container) - .' mc alias set'.$resolveOptions.' temporary '.escapeshellarg($s3->endpoint).' >/dev/null'; + .' mc alias set'.$resolveOptions.$s3->minioClientPathOption().' temporary '.escapeshellarg($s3->endpoint).' >/dev/null'; $timeout = (int) config('constants.ssh.command_timeout'); SshRetryHandler::retry( diff --git a/app/Livewire/Analytics.php b/app/Livewire/Analytics.php index 62c3aab048..e4cc20741b 100644 --- a/app/Livewire/Analytics.php +++ b/app/Livewire/Analytics.php @@ -348,9 +348,14 @@ class Analytics extends Component $appRows = []; $hostTotals = []; + $clients = $servers->mapWithKeys(fn (Server $server) => [$server->uuid => $this->trafficClient($server)]); + if ($resource === null) { + SentinelTrafficClient::prefetchServerWideConcurrently($clients, $from, $to, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS); + } + foreach ($servers as $server) { try { - $client = $this->trafficClient($server); + $client = $clients[$server->uuid]; if ($resource !== null) { // One server in Sentinel's resource scope is exact; a merge across servers diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index 05b295b88b..1aa03d854c 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -51,6 +51,7 @@ class Show extends Component $this->destination->name = $this->name; $changedFields = auditChangedFields($this->destination); $this->destination->save(); + $this->dispatch('name-changed', uuid: $this->destination->uuid, name: $this->destination->name); if ($changedFields !== []) { auditLog('ui.destination.updated', $this->auditContext([ 'changed_fields' => $changedFields, diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 118ba4461c..70918b6e54 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -321,16 +321,10 @@ class Domains extends Component if (! $this->isCompose) { $this->composeServices = []; } elseif ($parseCompose) { - try { - $parsed = $this->application->parse() ?? []; - } catch (\Throwable) { - $parsed = []; - } $this->composeServices = []; - $services = data_get($parsed, 'services', []); - foreach ($services as $serviceName => $service) { + foreach ($this->application->composeServicesWithoutParse() as $serviceName => $service) { if (! isDatabaseImage(data_get($service, 'image'))) { - $this->composeServices[] = $serviceName; + $this->composeServices[] = (string) $serviceName; } } } diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index a58d87f307..7128f2bbc6 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -170,9 +170,9 @@ class General extends Component 'gitRepository' => 'required', 'gitBranch' => ['required', 'string', new ValidGitBranch], 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], - 'installCommand' => ValidationPatterns::shellSafeCommandRules(), - 'buildCommand' => ValidationPatterns::shellSafeCommandRules(), - 'startCommand' => ValidationPatterns::shellSafeCommandRules(), + 'installCommand' => $this->changedCommandRules('installCommand', 'install_command'), + 'buildCommand' => $this->changedCommandRules('buildCommand', 'build_command'), + 'startCommand' => $this->changedCommandRules('startCommand', 'start_command'), 'buildPack' => 'required', 'staticImage' => ['required', Rule::enum(StaticImageTypes::class)], 'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)), @@ -217,6 +217,19 @@ class General extends Component ]; } + /** + * Commands saved before they were validated stay valid until they change. The deployment escapes them as one + * argument, and the fields are hidden for build packs other than Nixpacks and Railpack, so a user cannot fix them. + */ + private function changedCommandRules(string $property, string $column): array + { + if (isset($this->application) && (string) $this->{$property} === (string) $this->application->getOriginal($column)) { + return ['nullable']; + } + + return ValidationPatterns::shellSafeCommandRules(); + } + protected function messages(): array { return array_merge( @@ -973,6 +986,7 @@ class General extends Component $this->application->custom_labels = base64_encode($this->customLabels); $this->application->save(); $this->application->refresh(); + $this->dispatch('name-changed', uuid: $this->application->uuid, name: $this->application->name); $dnsCleanup->queueReleaseOfRemovedHostnames($this->application, $previousDnsHostnames, $this->application->team()->id); $this->syncData(); if ($oldPortsExposes !== $this->application->ports_exposes || $oldCustomNetworkAliases !== $this->application->custom_network_aliases) { diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 7de6552b94..6ae092610f 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -98,7 +98,7 @@ class Previews extends Component if (! $this->application->isGithubAppSource()) { throw new \Exception('Loading pull requests is only supported for GitHub App sources.'); } - ['rate_limit_remaining' => $rate_limit_remaining, 'data' => $data] = githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/pulls"); + ['rate_limit_remaining' => $rate_limit_remaining, 'data' => $data] = githubApi(source: $this->application->source, endpoint: '/repos/'.gitRepositorySlug($this->application->git_repository).'/pulls'); $this->rate_limit_remaining = $rate_limit_remaining; $this->pull_requests = $data->sortBy('number')->values(); } catch (\Throwable $e) { diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 35c71b3c5d..74f6255cb5 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -205,20 +205,21 @@ class Source extends Component $this->dispatch('configurationChanged'); ['repository' => $customRepository] = $this->application->customRepository(); + $repositorySlug = gitRepositorySlug($customRepository); $repository_project_id = null; if ($sourceType === GithubApp::class) { - $repository = githubApi($source, "repos/{$customRepository}"); + $repository = githubApi($source, "repos/{$repositorySlug}"); $repository_project_id = data_get($repository, 'data.id'); } elseif ($sourceType === GitlabApp::class) { if ($source->isConnected()) { - $encoded = urlencode($customRepository); + $encoded = urlencode($repositorySlug); $project = gitlabApi($source, "/projects/{$encoded}"); $repository_project_id = data_get($project, 'data.id'); } } elseif ($sourceType === GiteaApp::class) { if ($source->isConnected()) { - $repositoryPath = collect(explode('/', $customRepository, 2))->map(fn ($part) => rawurlencode($part))->implode('/'); + $repositoryPath = collect(explode('/', $repositorySlug, 2))->map(fn ($part) => rawurlencode($part))->implode('/'); $repository = giteaApi($source, "/repos/{$repositoryPath}"); $repository_project_id = data_get($repository, 'data.id'); } diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index fce8bffd0b..19a32b70be 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -63,7 +63,7 @@ class CloneMe extends Component ->where('team_id', $this->project->team_id) ->get() ->reject(fn ($server) => $server->isBuildServer()); - $this->newName = str($this->project->name.'-clone-'.new_public_id())->slug(); + $this->newName = str(clone_resource_name($this->project->name, new_public_id()))->slug(); } public function toggleVolumeCloning(bool $value) diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 68452e78af..c8badb3cf7 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -135,6 +135,7 @@ class General extends Component $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->save(); + $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { $this->name = $this->database->name; $this->description = $this->database->description; diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 3c6b27e306..0e35530841 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -122,6 +122,7 @@ class General extends Component $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->save(); + $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { $this->name = $this->database->name; $this->description = $this->database->description; diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php index 7fe8c8abef..a1a6783b8e 100644 --- a/app/Livewire/Project/Database/ImportForm.php +++ b/app/Livewire/Project/Database/ImportForm.php @@ -648,7 +648,7 @@ class ImportForm extends Component 'secret' => $s3Storage->secret, 'bucket' => $s3Storage->bucket, 'endpoint' => $s3Storage->endpoint, - 'use_path_style_endpoint' => true, + 'use_path_style_endpoint' => $s3Storage->usesPathStyleEndpoint(), 'http' => SafeWebhookUrl::httpClientOptions($s3Storage->endpoint), ]); diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 1e159d2f9b..abd9e5d025 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -126,6 +126,7 @@ class General extends Component $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->save(); + $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { $this->name = $this->database->name; $this->description = $this->database->description; diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index d65136e9f0..9e951db774 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -151,6 +151,7 @@ class General extends Component $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->save(); + $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { $this->name = $this->database->name; $this->description = $this->database->description; diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 02357a5581..3943512636 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -143,6 +143,7 @@ class General extends Component $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->save(); + $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { $this->name = $this->database->name; $this->description = $this->database->description; diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index fd3abfc218..f05f9b8410 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -151,6 +151,7 @@ class General extends Component $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->save(); + $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { $this->name = $this->database->name; $this->description = $this->database->description; diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 30e03d7630..f5844d93d1 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -167,6 +167,7 @@ class General extends Component $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->save(); + $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { $this->name = $this->database->name; $this->description = $this->database->description; diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index 5c96bef77a..f744f8eed6 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -144,6 +144,7 @@ class General extends Component $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->save(); + $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { $this->name = $this->database->name; $this->description = $this->database->description; diff --git a/app/Livewire/Project/Database/Sqlite/General.php b/app/Livewire/Project/Database/Sqlite/General.php index 59e05363d3..cd1d578554 100644 --- a/app/Livewire/Project/Database/Sqlite/General.php +++ b/app/Livewire/Project/Database/Sqlite/General.php @@ -81,6 +81,7 @@ class General extends Component $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->save(); + $this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name); } else { $this->name = $this->database->name; $this->description = $this->database->description; diff --git a/app/Livewire/Project/Edit.php b/app/Livewire/Project/Edit.php index 0d42c71e94..a22d7d8115 100644 --- a/app/Livewire/Project/Edit.php +++ b/app/Livewire/Project/Edit.php @@ -85,6 +85,7 @@ class Edit extends Component 'name' => $this->name, 'description' => $this->description, ]); + $this->dispatch('name-changed', uuid: $this->project->uuid, name: $this->project->name); } else { $this->name = $this->project->name; $this->description = $this->project->description; diff --git a/app/Livewire/Project/New/GiteaPrivateRepository.php b/app/Livewire/Project/New/GiteaPrivateRepository.php index 83247821a9..9c1fb558c8 100644 --- a/app/Livewire/Project/New/GiteaPrivateRepository.php +++ b/app/Livewire/Project/New/GiteaPrivateRepository.php @@ -170,10 +170,12 @@ class GiteaPrivateRepository extends Component 'selected_repository_path' => $this->selected_repository_path, 'selected_branch_name' => $this->selected_branch_name, 'docker_compose_location' => $this->docker_compose_location, + 'base_directory' => $this->base_directory, ], [ 'selected_repository_path' => 'required|string', 'selected_branch_name' => ['required', 'string', new ValidGitBranch], 'docker_compose_location' => ValidationPatterns::filePathRules(), + 'base_directory' => ValidationPatterns::directoryPathRules(), ]); if ($validator->fails()) { @@ -181,7 +183,7 @@ class GiteaPrivateRepository extends Component } $destination_uuid = $this->query['destination'] ?? null; - $destination = find_destination_for_current_team($destination_uuid); + $destination = find_resource_destination_for_current_team($destination_uuid); if (! $destination) { throw new \Exception('Destination not found.'); } diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index fb2ee74c13..e1a1d8e7f4 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -115,17 +115,22 @@ class GithubPrivateRepository extends Component ->where('is_public', false) ->whereNotNull('app_id') ->findOrFail($github_app_id); - $token = generateGithubInstallationToken($this->github_app); - $repositories = loadRepositoryByPage($this->github_app, $token, $this->page); - $this->total_repositories_count = $repositories['total_count']; - $this->repositories = $this->repositories->concat(collect($repositories['repositories'])); - if ($this->repositories->count() < $this->total_repositories_count) { + try { + $token = generateGithubInstallationToken($this->github_app); + $repositories = loadRepositoryByPage($this->github_app, $token, $this->page); + $this->total_repositories_count = $repositories['total_count']; + $this->repositories = $this->repositories->concat(collect($repositories['repositories'])); while ($this->repositories->count() < $this->total_repositories_count) { $this->page++; $repositories = loadRepositoryByPage($this->github_app, $token, $this->page); $this->total_repositories_count = $repositories['total_count']; $this->repositories = $this->repositories->concat(collect($repositories['repositories'])); } + } catch (\Throwable $e) { + $this->repositories = collect(); + handleError($e, $this); + + return; } $this->repositories = $this->repositories->sortBy('name'); if ($this->repositories->count() > 0) { @@ -141,12 +146,17 @@ class GithubPrivateRepository extends Component $this->selected_repository_repo = data_get($repository, 'name'); $this->branches = collect(); $this->page = 1; - $this->loadBranchByPage(); - if ($this->total_branches_count === 100) { - while ($this->total_branches_count === 100) { - $this->page++; + try { + do { $this->loadBranchByPage(); - } + $this->page++; + } while ($this->total_branches_count === 100); + } catch (\Throwable $e) { + $this->branches = collect(); + $this->total_branches_count = 0; + handleError($e, $this); + + return; } $this->branches = sortBranchesByPriority($this->branches); $defaultBranch = data_get($repository, 'default_branch', 'main'); @@ -157,7 +167,7 @@ class GithubPrivateRepository extends Component $this->detectRepository(); } - protected function loadBranchByPage() + protected function loadBranchByPage(): void { $token = generateGithubInstallationToken($this->github_app); @@ -170,7 +180,9 @@ class GithubPrivateRepository extends Component ]); $json = $response->json(); if ($response->status() !== 200) { - return $this->dispatch('error', $json['message']); + $error = data_get($json, 'message', "HTTP {$response->status()}"); + + throw new \RuntimeException("Failed to load branches of {$this->selected_repository_owner}/{$this->selected_repository_repo} with error: ".$error); } $this->total_branches_count = count($json); @@ -195,11 +207,13 @@ class GithubPrivateRepository extends Component 'selected_repository_repo' => $this->selected_repository_repo, 'selected_branch_name' => $this->selected_branch_name, 'docker_compose_location' => $this->docker_compose_location, + 'base_directory' => $this->base_directory, ], [ 'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/', 'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/', 'selected_branch_name' => ['required', 'string', new ValidGitBranch], 'docker_compose_location' => ValidationPatterns::filePathRules(), + 'base_directory' => ValidationPatterns::directoryPathRules(), ]); if ($validator->fails()) { diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index cf579429ed..f7f57acd02 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -40,9 +40,9 @@ class GithubPrivateRepositoryDeployKey extends Component public ?string $publish_directory = null; - // In case of docker compose - public ?string $base_directory = null; + public ?string $base_directory = '/'; + // In case of docker compose public ?string $docker_compose_location = '/docker-compose.yaml'; // End of docker compose @@ -71,6 +71,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'is_static' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', + 'base_directory' => ValidationPatterns::directoryPathRules(), 'docker_compose_location' => ValidationPatterns::filePathRules(), ]; } @@ -82,6 +83,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'is_static' => 'Is static', 'publish_directory' => 'Publish directory', 'build_pack' => 'Build pack', + 'base_directory' => 'Base directory', ]; public function mount() @@ -182,6 +184,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'destination_id' => $destination->id, 'destination_type' => $destination_class, 'private_key_id' => $privateKey->id, + 'base_directory' => $this->base_directory, ]; } else { $application_init = [ @@ -197,6 +200,7 @@ class GithubPrivateRepositoryDeployKey extends Component 'private_key_id' => $privateKey->id, 'source_id' => $this->git_source->id, 'source_type' => $this->git_source->getMorphClass(), + 'base_directory' => $this->base_directory, ]; } if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { @@ -207,7 +211,6 @@ class GithubPrivateRepositoryDeployKey extends Component } if ($this->build_pack === 'dockercompose') { $application_init['docker_compose_location'] = $this->docker_compose_location; - $application_init['base_directory'] = $this->base_directory; } $application = new Application($application_init); $application->save(); diff --git a/app/Livewire/Project/New/GitlabPrivateRepository.php b/app/Livewire/Project/New/GitlabPrivateRepository.php index 849ba7ccd1..fe30d8bb50 100644 --- a/app/Livewire/Project/New/GitlabPrivateRepository.php +++ b/app/Livewire/Project/New/GitlabPrivateRepository.php @@ -165,10 +165,12 @@ class GitlabPrivateRepository extends Component 'selected_repository_path' => $this->selected_repository_path, 'selected_branch_name' => $this->selected_branch_name, 'docker_compose_location' => $this->docker_compose_location, + 'base_directory' => $this->base_directory, ], [ 'selected_repository_path' => 'required|string', 'selected_branch_name' => ['required', 'string', new ValidGitBranch], 'docker_compose_location' => ValidationPatterns::filePathRules(), + 'base_directory' => ValidationPatterns::directoryPathRules(), ]); if ($validator->fails()) { @@ -176,7 +178,7 @@ class GitlabPrivateRepository extends Component } $destination_uuid = $this->query['destination'] ?? null; - $destination = find_destination_for_current_team($destination_uuid); + $destination = find_resource_destination_for_current_team($destination_uuid); if (! $destination) { throw new \Exception('Destination not found.'); } diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index d2a3a5a11f..daf713a679 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -73,7 +73,7 @@ class PublicGitRepository extends Component 'isStatic' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', - 'base_directory' => 'nullable|string', + 'base_directory' => ValidationPatterns::directoryPathRules(), 'docker_compose_location' => ValidationPatterns::filePathRules(), 'git_branch' => ['required', 'string', new ValidGitBranch], ]; @@ -351,7 +351,6 @@ class PublicGitRepository extends Component } if ($this->build_pack === 'dockercompose') { $application_init['docker_compose_location'] = $this->docker_compose_location; - $application_init['base_directory'] = $this->base_directory; } $application = new Application($application_init); $application->save(); diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index 2c91c31c35..ed5025ab1b 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -168,8 +168,6 @@ class Index extends Component return; } $this->parameters = get_route_parameters(); - $this->query = request()->query(); - $this->currentRoute = request()->route()->getName(); $project = currentTeam() ->projects() ->select('id', 'uuid', 'team_id') @@ -181,32 +179,12 @@ class Index extends Component ->firstOrFail(); $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail(); $this->authorize('view', $this->service); - if (in_array($this->currentRoute, ['project.service.index', 'project.service.index.advanced'], true)) { - return redirect()->route( - 'project.service.configuration', - collect($this->parameters)->except('stack_service_uuid')->all(), - ); - } - $service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first(); - if ($service) { - $this->serviceApplication = $service; - $this->resourceType = 'application'; - $this->serviceApplication->getFilesFromServer(); - $this->initializeApplicationProperties(); - } else { - $this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first(); - if (! $this->serviceDatabase) { - return redirect()->route('project.service.configuration', [ - 'project_uuid' => $this->parameters['project_uuid'], - 'environment_uuid' => $this->parameters['environment_uuid'], - 'service_uuid' => $this->parameters['service_uuid'], - ]); - } - $this->resourceType = 'database'; - $this->serviceDatabase->getFilesFromServer(); - $this->initializeDatabaseProperties(); - } - $this->s3s = $this->serviceTeamS3Storages(); + + // The settings of a stack service open in a modal on the configuration page. + return redirect()->route( + 'project.service.configuration', + collect($this->parameters)->except('stack_service_uuid')->all(), + ); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index 9247049d6d..cc6a994b36 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -199,6 +199,7 @@ class StackForm extends Component // Refresh and write files after a successful commit $this->service->refresh(); $this->service->saveComposeConfigs(); + $this->dispatch('name-changed', uuid: $this->service->uuid, name: $this->service->name); $this->dispatch('refreshEnvs')->to(EditCompose::class); $this->dispatch('refreshEnvs')->to(All::class); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 1591fca861..37f3f1b59a 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -986,23 +986,12 @@ class All extends Component private function formatEnvironmentVariables($variables) { - $hideValues = ! $this->canViewEnvironmentValues(); + $canViewValues = $this->canViewEnvironmentValues(); return $variables ->reject(fn ($item): bool => $this->isProtectedEnvironmentVariable($item->key)) - ->map(function ($item) use ($hideValues) { - if ($hideValues) { - return "$item->key=(Hidden, only admins can view)"; - } - if ($item->is_shown_once) { - return "$item->key=(Locked Secret, delete and add again to change)"; - } - if ($item->is_multiline) { - return "$item->key=(Multiline environment variable, edit in normal view)"; - } - - return "$item->key=$item->value"; - })->join("\n"); + ->map(fn ($item) => formatEnvVariableForDevView($item, $canViewValues)) + ->join("\n"); } public function switch() @@ -1017,11 +1006,28 @@ class All extends Component public function submit($data = null) { + // Parse before the save, so that an invalid "# coolify:" line changes nothing and keeps the edited text. try { $this->authorize('manageEnvironment', $this->resource); $this->ensureEnvironmentVariablesLoaded(); if ($data === null) { - $this->handleBulkSubmit(); + $variables = $this->normalizeEnvironmentVariables(parseEnvDevView($this->variables, EnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS)); + $previewVariables = []; + if ($this->showPreview) { + try { + $previewVariables = $this->normalizeEnvironmentVariables(parseEnvDevView($this->variablesPreview, EnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS)); + } catch (\InvalidArgumentException $e) { + throw new \InvalidArgumentException('Preview deployments: '.$e->getMessage(), previous: $e); + } + } + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + + try { + if ($data === null) { + $this->handleBulkSubmit($variables, $previewVariables); } else { $this->handleSingleSubmit($data); } @@ -1071,9 +1077,8 @@ class All extends Component } } - private function handleBulkSubmit() + private function handleBulkSubmit(array $variables, array $previewVariables) { - $variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables)); $changesMade = false; $errorOccurred = false; @@ -1092,8 +1097,6 @@ class All extends Component } if ($this->showPreview) { - $previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview)); - // Try to delete removed preview variables $deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables); if ($deletedPreviewCount > 0) { @@ -1223,7 +1226,7 @@ class All extends Component $found = $this->resource->$method()->where('key', $key)->first(); if ($found) { - if (! $found->is_shown_once && ! $found->is_multiline) { + if (! $found->is_shown_once && ! $found->is_multiline && formatEnvValueForDevView($found->value) !== null) { $changed = false; // Update value if it changed @@ -1239,7 +1242,10 @@ class All extends Component $changed = true; } - if ($changed) { + // Flags change only when a "# coolify:" line sets them + $found->fill($data['settings']); + + if ($changed || $found->isDirty()) { $found->save(); $count++; } @@ -1250,6 +1256,7 @@ class All extends Component $environment->value = $value; $environment->comment = $comment; // Set comment from inline comment $environment->is_multiline = false; + $environment->fill($data['settings']); $environment->is_preview = $isPreview; $environment->resourceable_id = $this->resource->id; $environment->resourceable_type = $this->resource->getMorphClass(); diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index 57bfbd16f3..4777345546 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -297,7 +297,7 @@ class GetLogs extends Component $stripTimestamps ??= $export && ! $this->showTimeStamps; $merged = $this->joinMergedLines($groups, $stripTimestamps, $export); if (strlen($merged) > $maxBytes) { - $merged = rtrim(substr($merged, 0, $maxBytes), "\n"); + $merged = rtrim(mb_strcut($merged, 0, $maxBytes, 'UTF-8'), "\n"); $truncated = true; } if ($truncated && $merged !== '') { @@ -502,7 +502,8 @@ class GetLogs extends Component ->run(SshMultiplexingHelper::generateSshBatchCommand($server)) ->output(); $truncated = strlen($output) > $maxBytes; - $logs = rtrim(removeAnsiColors($truncated ? substr($output, 0, $maxBytes) : $output), "\n"); + // Invalid UTF-8 from a container would make the JSON response fail, so the viewer would stay empty. + $logs = rtrim(sanitize_utf8_text(removeAnsiColors($truncated ? substr($output, 0, $maxBytes) : $output)), "\n"); return ['logs' => $logs, 'truncated' => $truncated]; } diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 198972406e..6173215742 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -132,7 +132,7 @@ class ResourceOperations extends Component 'updated_at', ])->fill([ 'uuid' => $uuid, - 'name' => $this->resource->name.'-clone-'.$uuid, + 'name' => clone_resource_name($this->resource->name, $uuid), 'status' => 'exited', 'started_at' => null, 'environment_id' => $new_environment->id, diff --git a/app/Livewire/Project/Shared/ScheduledTask/All.php b/app/Livewire/Project/Shared/ScheduledTask/All.php index 124c907210..971554f6a1 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/All.php +++ b/app/Livewire/Project/Shared/ScheduledTask/All.php @@ -44,9 +44,7 @@ class All extends Component $this->containerNames = $this->containerNames->merge($this->resource->databases()->pluck('name')); } elseif ($this->resource->type() === 'application') { if ($this->resource->build_pack === 'dockercompose') { - $parsed = $this->resource->parse(); - $containers = collect(data_get($parsed, 'services'))->keys(); - $this->containerNames = $containers; + $this->containerNames = collect($this->resource->composeServicesWithoutParse())->keys(); } else { $this->containerNames = collect([]); } diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index 811b7f7712..e39f661943 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -6,6 +6,7 @@ use App\Models\IntegrationToken; use App\Services\Dns\CloudflareDnsProvider; use App\Services\IntegrationTokenValidator; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\DB; use Livewire\Component; @@ -180,6 +181,8 @@ class IntegrationTokenEditor extends Component capabilities: $this->integrationToken->capabilities, ); $this->dispatch('success', 'Integration token updated successfully.'); + } catch (ConnectionException) { + $this->dispatch('error', $validator->connectionErrorMessage($provider)); } catch (\Throwable $e) { handleError($e, $this); } diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php index f05a5879f6..33f45d9765 100644 --- a/app/Livewire/Security/IntegrationTokenForm.php +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -6,6 +6,7 @@ use App\Models\IntegrationToken; use App\Services\Dns\CloudflareDnsProvider; use App\Services\IntegrationTokenValidator; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\DB; use Livewire\Component; @@ -150,6 +151,8 @@ class IntegrationTokenForm extends Component } $this->dispatch('success', 'Integration token added successfully.'); + } catch (ConnectionException) { + $this->dispatch('error', $validator->connectionErrorMessage($validated['provider'])); } catch (\Throwable $e) { handleError($e, $this); } diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index 0ba40a3bbf..6ae6a357c4 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -5,7 +5,9 @@ namespace App\Livewire\Server; use App\Actions\Server\StartLogDrain; use App\Actions\Server\StopLogDrain; use App\Models\Server; +use App\Models\ServerSetting; use App\Rules\ValidFluentBitConfig; +use App\Rules\ValidHostname; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Validation\ValidationException; use Livewire\Attributes\Validate; @@ -38,6 +40,9 @@ class LogDrains extends Component #[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])] public ?string $logDrainAxiomApiKey = null; + #[Validate(['string', 'nullable', new ValidHostname])] + public ?string $logDrainAxiomDomain = null; + #[Validate(['string', 'nullable'])] public ?string $logDrainCustomConfig = null; @@ -87,9 +92,11 @@ class LogDrains extends Component $this->server->settings->is_logdrain_axiom_enabled = $this->isLogDrainAxiomEnabled; $this->server->settings->logdrain_axiom_dataset_name = $this->logDrainAxiomDatasetName; $this->server->settings->logdrain_axiom_api_key = $this->logDrainAxiomApiKey; + $this->server->settings->logdrain_axiom_domain = $this->logDrainAxiomDomain; } else { $this->isLogDrainAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled; $this->logDrainAxiomDatasetName = $this->server->settings->logdrain_axiom_dataset_name; + $this->logDrainAxiomDomain = $this->server->settings->logdrain_axiom_domain ?: ServerSetting::DEFAULT_AXIOM_DOMAIN; $this->logDrainAxiomApiKey = auth()->user()->can('update', $this->server) ? $this->server->settings->logdrain_axiom_api_key : null; @@ -146,9 +153,6 @@ class LogDrains extends Component $this->syncDataCustom($toModel); $this->syncDataCloudwatch($toModel); } - if ($this->enabledLogDrainCount() > 0) { - $this->server->settings->is_logdrain_highlight_enabled = false; - } $this->auditLogDrain('updated'); $this->server->settings->save(); } else { @@ -195,6 +199,7 @@ class LogDrains extends Component $this->validate([ 'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], 'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + 'logDrainAxiomDomain' => ['required', new ValidHostname], ]); } catch (\Throwable $e) { $this->isLogDrainAxiomEnabled = false; @@ -240,6 +245,8 @@ class LogDrains extends Component StopLogDrain::run($this->server); $this->dispatch('success', 'Log drain service stopped.'); } + } catch (ValidationException $e) { + return $this->handleValidationError($e); } catch (\Throwable $e) { return handleError($e, $this); } @@ -251,7 +258,6 @@ class LogDrains extends Component $previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled; $previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled; $previousCloudwatchEnabled = $this->server->settings->is_logdrain_cloudwatch_enabled; - $previousHighlightEnabled = $this->server->settings->is_logdrain_highlight_enabled; try { $this->authorize('update', $this->server); @@ -279,6 +285,9 @@ class LogDrains extends Component StopLogDrain::run($this->server); $this->dispatch('success', 'Log drain service stopped.'); } + } catch (ValidationException $e) { + // Validation runs before anything is saved, so keep the typed values and show the errors under the fields. + $this->handleValidationError($e); } catch (\Throwable $e) { // Restore the previously persisted enabled flags so the UI/DB never // claim a runtime state that the Start/StopLogDrain action failed to apply. @@ -286,7 +295,6 @@ class LogDrains extends Component $this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled; $this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled; $this->server->settings->is_logdrain_cloudwatch_enabled = $previousCloudwatchEnabled; - $this->server->settings->is_logdrain_highlight_enabled = $previousHighlightEnabled; $this->server->settings->save(); $this->syncData(); @@ -310,6 +318,13 @@ class LogDrains extends Component return view('livewire.server.log-drains'); } + private function handleValidationError(ValidationException $e): mixed + { + $this->setErrorBag($e->validator->errors()); + + return handleError($e, $this); + } + private function enabledLogDrainCount(): int { return count(array_filter([ @@ -351,6 +366,7 @@ class LogDrains extends Component 'axiom' => $this->validate([ 'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], 'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + 'logDrainAxiomDomain' => ['required', new ValidHostname], ]), 'custom' => $this->validate([ 'logDrainCustomConfig' => ['required', new ValidFluentBitConfig], diff --git a/app/Livewire/Server/New/ByDigitalOcean.php b/app/Livewire/Server/New/ByDigitalOcean.php index 95e07643ef..6c6f4a4fcd 100644 --- a/app/Livewire/Server/New/ByDigitalOcean.php +++ b/app/Livewire/Server/New/ByDigitalOcean.php @@ -3,6 +3,7 @@ namespace App\Livewire\Server\New; use App\Enums\ProxyTypes; +use App\Jobs\ValidateNewCloudServerJob; use App\Models\CloudInitScript; use App\Models\CloudProviderToken; use App\Models\PrivateKey; @@ -519,6 +520,8 @@ class ByDigitalOcean extends Component report($e); } + ValidateNewCloudServerJob::dispatch($server); + if ($this->from_onboarding) { currentTeam()->update([ 'show_boarding' => false, diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 868b9e44b3..81ed3269ad 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -3,6 +3,8 @@ namespace App\Livewire\Server\New; use App\Enums\ProxyTypes; +use App\Exceptions\CloudProviderApiException; +use App\Jobs\ValidateNewCloudServerJob; use App\Models\CloudInitScript; use App\Models\CloudProviderToken; use App\Models\PrivateKey; @@ -12,7 +14,6 @@ use App\Rules\ValidCloudInitYaml; use App\Rules\ValidHostname; use App\Services\HetznerService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Http\Client\RequestException; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Illuminate\Validation\ValidationException; @@ -323,7 +324,7 @@ class ByHetzner extends Component $hetznerService = new HetznerService($token); $this->locations = $hetznerService->getLocations(); - $this->serverTypes = $hetznerService->getServerTypes(); + $this->serverTypes = $hetznerService->getServerTypes(includeOutOfStock: true); // Get images and sort by name $images = $hetznerService->getImages(); @@ -358,22 +359,13 @@ class ByHetzner extends Component $this->loading_data = false; } catch (\Throwable $e) { $this->loading_data = false; - $this->provider_data_error = $this->providerDataErrorMessage('Hetzner', $e, 'error.message'); + $this->provider_data_error = $e instanceof CloudProviderApiException + ? $e->getMessage() + : 'Hetzner API error: '.$e->getMessage(); $this->dispatch('error', $this->provider_data_error); } } - private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string - { - $details = $e->getMessage(); - - if ($e instanceof RequestException && $e->response) { - $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; - } - - return "{$providerName} API error: {$details}"; - } - private function getCpuVendorInfo(array $serverType): ?string { $name = strtolower($serverType['name'] ?? ''); @@ -394,7 +386,7 @@ class ByHetzner extends Component public function getAvailableServerTypesProperty() { if (! $this->selected_location) { - return $this->serverTypes; + return []; } $filtered = collect($this->serverTypes) @@ -409,15 +401,24 @@ class ByHetzner extends Component }) ->map(function ($serverType) { $serverType['cpu_vendor_info'] = $this->getCpuVendorInfo($serverType); + $serverType['out_of_stock'] = $this->isServerTypeOutOfStock($serverType); return $serverType; }) + ->sortBy('out_of_stock') ->values() ->toArray(); return $filtered; } + private function isServerTypeOutOfStock(array $serverType): bool + { + $typeLocation = collect($serverType['locations'] ?? [])->firstWhere('name', $this->selected_location); + + return ($typeLocation['available'] ?? true) === false; + } + public function getAvailableImagesProperty() { @@ -679,6 +680,13 @@ class ByHetzner extends Component { $this->validate(); + $selectedServerType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type); + if ($selectedServerType && $this->isServerTypeOutOfStock($selectedServerType)) { + $this->addError('selected_server_type', 'This server type is out of stock in the selected location. Select a different server type or location.'); + + return null; + } + if (! $this->enable_ipv4 && ! $this->enable_ipv6) { $this->addError('enable_ipv4', 'Enable at least one public IP protocol.'); $this->addError('enable_ipv6', 'Enable at least one public IP protocol.'); @@ -751,6 +759,8 @@ class ByHetzner extends Component } } + ValidateNewCloudServerJob::dispatch($server); + if ($this->from_onboarding) { // Complete the boarding when server is successfully created via Hetzner currentTeam()->update([ @@ -766,20 +776,31 @@ class ByHetzner extends Component if (! isset($e->errors()['server'])) { throw $e; } - if (isset($hetznerService, $hetznerServer['id'])) { - try { - $hetznerService->deleteServer((int) $hetznerServer['id']); - } catch (\Throwable $cleanupError) { - report($cleanupError); - } - } + $this->deleteUntrackedHetznerServer($hetznerService ?? null, $hetznerServer['id'] ?? null); return $this->dispatch('error', 'You have reached the server limit for your subscription.'); } catch (\Throwable $e) { + if (! isset($server)) { + $this->deleteUntrackedHetznerServer($hetznerService ?? null, $hetznerServer['id'] ?? null); + } + return handleError($e, $this); } } + private function deleteUntrackedHetznerServer(?HetznerService $hetznerService, ?int $hetznerServerId): void + { + if (! $hetznerService || ! $hetznerServerId) { + return; + } + + try { + $hetznerService->deleteServer($hetznerServerId); + } catch (\Throwable $e) { + report($e); + } + } + public function render() { return view('livewire.server.new.by-hetzner'); diff --git a/app/Livewire/Server/New/ByHostinger.php b/app/Livewire/Server/New/ByHostinger.php index c05fcf7d5d..f12db08511 100644 --- a/app/Livewire/Server/New/ByHostinger.php +++ b/app/Livewire/Server/New/ByHostinger.php @@ -3,6 +3,7 @@ namespace App\Livewire\Server\New; use App\Enums\ProxyTypes; +use App\Jobs\ValidateNewCloudServerJob; use App\Models\CloudProviderToken; use App\Models\PrivateKey; use App\Models\Server; @@ -377,6 +378,8 @@ class ByHostinger extends Component report($e); } + ValidateNewCloudServerJob::dispatch($server); + if ($this->from_onboarding) { currentTeam()->update(['show_boarding' => false]); refreshSession(); diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php index f3024f29da..5e27585b44 100644 --- a/app/Livewire/Server/New/ByVultr.php +++ b/app/Livewire/Server/New/ByVultr.php @@ -3,6 +3,7 @@ namespace App\Livewire\Server\New; use App\Enums\ProxyTypes; +use App\Jobs\ValidateNewCloudServerJob; use App\Models\CloudInitScript; use App\Models\CloudProviderToken; use App\Models\PrivateKey; @@ -487,6 +488,8 @@ class ByVultr extends Component report($e); } + ValidateNewCloudServerJob::dispatch($server); + if ($this->from_onboarding) { currentTeam()->update([ 'show_boarding' => false, diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 796ddcc82c..45b617fab1 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -86,14 +86,6 @@ class Show extends Component 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 @@ -780,7 +772,6 @@ class Show extends Component $this->hetznerServerStatus = 'starting'; $this->server->update(['hetzner_server_status' => 'starting']); - $this->hetznerServerManuallyStarted = true; // Set flag to trigger auto-validation when running $this->dispatch('success', 'Hetzner server is starting...'); } catch (\Throwable $e) { return handleError($e, $this); @@ -803,7 +794,6 @@ class Show extends Component $this->vultrInstanceStatus = 'starting'; $this->server->update(['vultr_instance_status' => 'starting']); - $this->vultrInstanceManuallyStarted = true; $this->dispatch('success', 'Vultr instance is starting...'); } catch (\Throwable $e) { return handleError($e, $this); @@ -826,7 +816,6 @@ class Show extends Component $this->digitalOceanDropletStatus = 'new'; $this->server->update(['digitalocean_droplet_status' => 'new']); - $this->digitalOceanDropletManuallyStarted = true; $this->dispatch('success', 'DigitalOcean droplet is starting...'); } catch (\Throwable $e) { return handleError($e, $this); @@ -849,7 +838,6 @@ class Show extends Component $this->hostingerVirtualMachineStatus = 'starting'; $this->server->update(['hostinger_virtual_machine_status' => 'starting']); - $this->hostingerVirtualMachineManuallyStarted = true; $this->dispatch('success', 'Hostinger VPS is starting...'); return null; diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index 84b2ffaa32..748c53ba2d 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -116,6 +116,22 @@ class ValidateAndInstall extends Component try { $this->authorize('update', $this->server); Server::throttleValidation($this->server->team_id, $this->server->ip); + if ($this->server->hetzner_server_id) { + $status = $this->server->refreshHetznerState(); + $this->server->refresh(); + + if (in_array($status, ['initializing', 'starting', 'stopping', 'off', 'deleting', 'rebuilding', 'migrating'], true)) { + $this->error = $status === 'off' + ? 'Hetzner server is off. Power it on before validating.' + : "Hetzner server is {$status}. Wait until it is running, then validate."; + $this->server->update([ + 'validation_logs' => $this->error, + ]); + + return; + } + } + if ($this->server->vultr_instance_id) { $status = $this->server->refreshVultrState(); $this->server->refresh(); diff --git a/app/Livewire/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php index 73c023f321..5e22376584 100644 --- a/app/Livewire/SharedVariables/Environment/Show.php +++ b/app/Livewire/SharedVariables/Environment/Show.php @@ -5,6 +5,7 @@ namespace App\Livewire\SharedVariables\Environment; use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Application; use App\Models\Project; +use App\Models\SharedEnvironmentVariable; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; use Livewire\Component; @@ -83,26 +84,21 @@ class Show extends Component { $canViewValues = auth()->user()?->isAdminOfTeam($this->project->team_id) ?? false; - return $variables->map(function ($item) use ($canViewValues) { - if (! $canViewValues) { - return "$item->key=(Hidden, only admins can view)"; - } - if ($item->is_shown_once) { - return "$item->key=(Locked Secret, delete and add again to change)"; - } - if ($item->is_multiline) { - return "$item->key=(Multiline environment variable, edit in normal view)"; - } - - return "$item->key=$item->value"; - })->join("\n"); + return $variables->map(fn ($item) => formatEnvVariableForDevView($item, $canViewValues))->join("\n"); } public function submit() { + // Parse before the save, so that an invalid "# coolify:" line changes nothing and keeps the edited text. try { $this->authorize('update', $this->environment); - $this->handleBulkSubmit(); + $variables = parseEnvDevView($this->variables, SharedEnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS); + } catch (\Throwable $e) { + return handleError($e, $this); + } + + try { + $this->handleBulkSubmit($variables); $this->getDevView(); } catch (\Throwable $e) { return handleError($e, $this); @@ -111,9 +107,8 @@ class Show extends Component } } - private function handleBulkSubmit() + private function handleBulkSubmit(array $variables) { - $variables = parseEnvFormatToArray($this->variables); $changesMade = false; DB::transaction(function () use ($variables, &$changesMade) { @@ -158,9 +153,12 @@ class Show extends Component $found = $this->environment->environment_variables()->where('key', $key)->first(); if ($found) { - if (! $found->is_shown_once && ! $found->is_multiline) { + if (! $found->is_shown_once && ! $found->is_multiline && formatEnvValueForDevView($found->value) !== null) { if ($found->value !== $value) { $found->value = $value; + } + $found->fill($data['settings']); + if ($found->isDirty()) { $found->save(); $count++; } @@ -171,6 +169,7 @@ class Show extends Component 'value' => $value, 'is_multiline' => false, 'is_literal' => false, + ...$data['settings'], 'type' => 'environment', 'team_id' => $this->project->team_id, ]); diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index 73590db941..b2973291ca 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -4,6 +4,7 @@ namespace App\Livewire\SharedVariables\Project; use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Project; +use App\Models\SharedEnvironmentVariable; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; use Livewire\Component; @@ -81,26 +82,21 @@ class Show extends Component { $canViewValues = auth()->user()?->isAdminOfTeam($this->project->team_id) ?? false; - return $variables->map(function ($item) use ($canViewValues) { - if (! $canViewValues) { - return "$item->key=(Hidden, only admins can view)"; - } - if ($item->is_shown_once) { - return "$item->key=(Locked Secret, delete and add again to change)"; - } - if ($item->is_multiline) { - return "$item->key=(Multiline environment variable, edit in normal view)"; - } - - return "$item->key=$item->value"; - })->join("\n"); + return $variables->map(fn ($item) => formatEnvVariableForDevView($item, $canViewValues))->join("\n"); } public function submit() { + // Parse before the save, so that an invalid "# coolify:" line changes nothing and keeps the edited text. try { $this->authorize('update', $this->project); - $this->handleBulkSubmit(); + $variables = parseEnvDevView($this->variables, SharedEnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS); + } catch (\Throwable $e) { + return handleError($e, $this); + } + + try { + $this->handleBulkSubmit($variables); $this->getDevView(); } catch (\Throwable $e) { return handleError($e, $this); @@ -109,10 +105,8 @@ class Show extends Component } } - private function handleBulkSubmit() + private function handleBulkSubmit(array $variables) { - $variables = parseEnvFormatToArray($this->variables); - $changesMade = DB::transaction(function () use ($variables) { // Delete removed variables $deletedCount = $this->deleteRemovedVariables($variables); @@ -150,9 +144,12 @@ class Show extends Component $found = $this->project->environment_variables()->where('key', $key)->first(); if ($found) { - if (! $found->is_shown_once && ! $found->is_multiline) { + if (! $found->is_shown_once && ! $found->is_multiline && formatEnvValueForDevView($found->value) !== null) { if ($found->value !== $value) { $found->value = $value; + } + $found->fill($data['settings']); + if ($found->isDirty()) { $found->save(); $count++; } @@ -163,6 +160,7 @@ class Show extends Component 'value' => $value, 'is_multiline' => false, 'is_literal' => false, + ...$data['settings'], 'type' => 'project', 'team_id' => $this->project->team_id, ]); diff --git a/app/Livewire/SharedVariables/Server/Show.php b/app/Livewire/SharedVariables/Server/Show.php index e775c50257..3c4d747e29 100644 --- a/app/Livewire/SharedVariables/Server/Show.php +++ b/app/Livewire/SharedVariables/Server/Show.php @@ -4,6 +4,7 @@ namespace App\Livewire\SharedVariables\Server; use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Server; +use App\Models\SharedEnvironmentVariable; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; use Livewire\Component; @@ -82,26 +83,21 @@ class Show extends Component { $canViewValues = auth()->user()?->isAdminOfTeam($this->server->team_id) ?? false; - return $variables->map(function ($item) use ($canViewValues) { - if (! $canViewValues) { - return "$item->key=(Hidden, only admins can view)"; - } - if ($item->is_shown_once) { - return "$item->key=(Locked Secret, delete and add again to change)"; - } - if ($item->is_multiline) { - return "$item->key=(Multiline environment variable, edit in normal view)"; - } - - return "$item->key=$item->value"; - })->join("\n"); + return $variables->map(fn ($item) => formatEnvVariableForDevView($item, $canViewValues))->join("\n"); } public function submit() { + // Parse before the save, so that an invalid "# coolify:" line changes nothing and keeps the edited text. try { $this->authorize('update', $this->server); - $this->handleBulkSubmit(); + $variables = parseEnvDevView($this->variables, SharedEnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS); + } catch (\Throwable $e) { + return handleError($e, $this); + } + + try { + $this->handleBulkSubmit($variables); $this->getDevView(); } catch (\Throwable $e) { return handleError($e, $this); @@ -110,10 +106,8 @@ class Show extends Component } } - private function handleBulkSubmit() + private function handleBulkSubmit(array $variables) { - $variables = parseEnvFormatToArray($this->variables); - $changesMade = DB::transaction(function () use ($variables) { // Delete removed variables $deletedCount = $this->deleteRemovedVariables($variables); @@ -162,10 +156,13 @@ class Show extends Component $found = $this->server->environment_variables()->where('key', $key)->first(); if ($found) { - if (! $found->is_shown_once && ! $found->is_multiline) { - if ($found->value !== $value || $found->comment !== $comment) { + if (! $found->is_shown_once && ! $found->is_multiline && formatEnvValueForDevView($found->value) !== null) { + if ($found->value !== $value) { $found->value = $value; - $found->comment = $comment; + } + $found->comment = $comment; + $found->fill($data['settings']); + if ($found->isDirty()) { $found->save(); $count++; } @@ -177,6 +174,7 @@ class Show extends Component 'comment' => $comment, 'is_multiline' => false, 'is_literal' => false, + ...$data['settings'], 'type' => 'server', 'team_id' => $this->server->team_id, ]); diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index 034e71c223..682cbeee18 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -3,6 +3,7 @@ namespace App\Livewire\SharedVariables\Team; use App\Livewire\Concerns\RemembersCanvasView; +use App\Models\SharedEnvironmentVariable; use App\Models\Team; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; @@ -75,26 +76,21 @@ class Index extends Component { $canViewValues = auth()->user()?->isAdminOfTeam($this->team->id) ?? false; - return $variables->map(function ($item) use ($canViewValues) { - if (! $canViewValues) { - return "$item->key=(Hidden, only admins can view)"; - } - if ($item->is_shown_once) { - return "$item->key=(Locked Secret, delete and add again to change)"; - } - if ($item->is_multiline) { - return "$item->key=(Multiline environment variable, edit in normal view)"; - } - - return "$item->key=$item->value"; - })->join("\n"); + return $variables->map(fn ($item) => formatEnvVariableForDevView($item, $canViewValues))->join("\n"); } public function submit() { + // Parse before the save, so that an invalid "# coolify:" line changes nothing and keeps the edited text. try { $this->authorize('update', $this->team); - $this->handleBulkSubmit(); + $variables = parseEnvDevView($this->variables, SharedEnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS); + } catch (\Throwable $e) { + return handleError($e, $this); + } + + try { + $this->handleBulkSubmit($variables); $this->getDevView(); } catch (\Throwable $e) { return handleError($e, $this); @@ -103,9 +99,8 @@ class Index extends Component } } - private function handleBulkSubmit() + private function handleBulkSubmit(array $variables) { - $variables = parseEnvFormatToArray($this->variables); $changesMade = false; DB::transaction(function () use ($variables, &$changesMade) { @@ -149,9 +144,12 @@ class Index extends Component $found = $this->team->environment_variables()->where('key', $key)->first(); if ($found) { - if (! $found->is_shown_once && ! $found->is_multiline) { + if (! $found->is_shown_once && ! $found->is_multiline && formatEnvValueForDevView($found->value) !== null) { if ($found->value !== $value) { $found->value = $value; + } + $found->fill($data['settings']); + if ($found->isDirty()) { $found->save(); $count++; } @@ -162,6 +160,7 @@ class Index extends Component 'value' => $value, 'is_multiline' => false, 'is_literal' => false, + ...$data['settings'], 'type' => 'team', 'team_id' => $this->team->id, ]); diff --git a/app/Livewire/Source/Gitea/Change.php b/app/Livewire/Source/Gitea/Change.php index 5980ed3fbf..6db104020e 100644 --- a/app/Livewire/Source/Gitea/Change.php +++ b/app/Livewire/Source/Gitea/Change.php @@ -282,6 +282,7 @@ class Change extends Component $this->syncData(true); $this->gitea_app->save(); $this->dispatch('success', 'Gitea App updated.'); + $this->dispatch('name-changed', uuid: $this->gitea_app->uuid, name: $this->gitea_app->name); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 5ec577f2e4..60d65804e2 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -431,6 +431,7 @@ class Change extends Component $this->syncData(true); $this->github_app->save(); $this->isConnected = $this->github_app->isConnected(); + $this->dispatch('name-changed', uuid: $this->github_app->uuid, name: $this->github_app->name); $this->dispatch('success', 'Github App updated.'); } catch (ValidationException $e) { throw $e; diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php index a7ff5b47e8..d599c40257 100644 --- a/app/Livewire/Source/Gitlab/Change.php +++ b/app/Livewire/Source/Gitlab/Change.php @@ -282,6 +282,7 @@ class Change extends Component $this->syncData(true); $this->gitlab_app->save(); $this->dispatch('success', 'GitLab App updated.'); + $this->dispatch('name-changed', uuid: $this->gitlab_app->uuid, name: $this->gitlab_app->name); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Storage/Create.php b/app/Livewire/Storage/Create.php index 9e22e5491e..10dfb17678 100644 --- a/app/Livewire/Storage/Create.php +++ b/app/Livewire/Storage/Create.php @@ -29,6 +29,10 @@ class Create extends Component public string $endpoint = ''; + public bool $usePathStyleEndpoint = true; + + public bool $usePathStyleEndpointChanged = false; + public array $endpointParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; public bool $endpointPartsChanged = false; @@ -45,6 +49,7 @@ class Create extends Component 'secret' => 'required|max:255', 'bucket' => ['required', new ValidS3BucketName], 'endpoint' => ['required', 'max:255', new SafeWebhookUrl], + 'usePathStyleEndpoint' => 'boolean', ]; } @@ -74,6 +79,7 @@ class Create extends Component 'secret' => 'Secret', 'bucket' => 'Bucket', 'endpoint' => 'Endpoint', + 'usePathStyleEndpoint' => 'Use path-style URLs', ]; public function submit() @@ -93,6 +99,7 @@ class Create extends Component $this->storage->key = $this->key; $this->storage->secret = $this->secret; $this->storage->bucket = $this->bucket; + $this->storage->use_path_style_endpoint = $this->usePathStyleEndpoint; if (empty($this->endpoint)) { $this->storage->endpoint = "https://s3.{$this->region}.amazonaws.com"; } else { @@ -112,6 +119,14 @@ class Create extends Component public function updatedEndpointParts(): void { $this->endpointPartsChanged = true; + if (! $this->usePathStyleEndpointChanged) { + $this->usePathStyleEndpoint = S3Storage::detectPathStyleEndpoint(DomainUrlParts::compose(...$this->endpointParts)); + } + } + + public function updatedUsePathStyleEndpoint(): void + { + $this->usePathStyleEndpointChanged = true; } private function connectionErrorDescription(\Throwable $exception): string diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index 6cd161306e..4f5b01b576 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -33,6 +33,10 @@ class Form extends Component public string $region; + public bool $usePathStyleEndpoint = true; + + public bool $usePathStyleEndpointChanged = false; + public string $key; public string $secret; @@ -52,6 +56,7 @@ class Form extends Component 'secret' => 'required|max:255', 'bucket' => ['required', new ValidS3BucketName], 'endpoint' => ['required', 'max:255', new SafeWebhookUrl], + 'usePathStyleEndpoint' => 'boolean', ]; } @@ -82,6 +87,7 @@ class Form extends Component 'secret' => 'Secret', 'bucket' => 'Bucket', 'endpoint' => 'Endpoint', + 'usePathStyleEndpoint' => 'Use path-style URLs', ]; /** @@ -98,6 +104,7 @@ class Form extends Component $this->storage->endpoint = $this->endpoint; $this->storage->bucket = $this->bucket; $this->storage->region = $this->region; + $this->storage->use_path_style_endpoint = $this->usePathStyleEndpoint; $this->storage->key = $this->key; $this->storage->secret = $this->secret; $this->storage->is_usable = $this->isUsable; @@ -110,6 +117,9 @@ class Form extends Component $this->endpointPartsChanged = false; $this->bucket = $this->storage->bucket; $this->region = $this->storage->region; + $this->usePathStyleEndpoint = $this->storage->usesPathStyleEndpoint(); + // A saved style that detection would not pick is a user choice, so keep it when the endpoint changes. + $this->usePathStyleEndpointChanged = $this->usePathStyleEndpoint !== S3Storage::detectPathStyleEndpoint($this->endpoint); $canSeeCredentials = auth()->user()?->can('update', $this->storage) ?? false; $this->key = $canSeeCredentials ? $this->storage->key : ''; $this->secret = $canSeeCredentials ? $this->storage->secret : ''; @@ -142,10 +152,13 @@ class Form extends Component $testedStorage->endpoint = $this->endpoint; $testedStorage->bucket = $this->bucket; $testedStorage->region = $this->region; + $testedStorage->use_path_style_endpoint = $this->usePathStyleEndpoint; $testedStorage->key = $this->key; $testedStorage->secret = $this->secret; $testedStorage->testConnection(); + $switchedToVirtualHostedStyle = $this->usePathStyleEndpoint && ! $testedStorage->usesPathStyleEndpoint(); + $this->usePathStyleEndpoint = $testedStorage->usesPathStyleEndpoint(); // Update component property to reflect the new validation status $this->isUsable = $testedStorage->is_usable; @@ -154,7 +167,9 @@ class Form extends Component $this->storage->save(); $this->dispatch('storage-status-changed', isUsable: $this->isUsable); - return $this->dispatch('success', 'Connection is working.', 'Tested with "ListObjectsV2" action.'); + return $this->dispatch('success', 'Connection is working.', $switchedToVirtualHostedStyle + ? 'The provider rejects path-style URLs, so "Use path-style URLs" is now off. Save to keep it.' + : 'Tested with "ListObjectsV2" action.'); } catch (\Throwable $e) { if ($testedStorage) { $this->isUsable = $testedStorage->is_usable; @@ -192,9 +207,11 @@ class Form extends Component $this->storage->unusable_email_sent = false; $this->storage->save(); - // Update local property to reflect success + // Update local properties to reflect success $this->isUsable = true; + $this->usePathStyleEndpoint = $this->storage->usesPathStyleEndpoint(); }); + $this->dispatch('name-changed', uuid: $this->storage->uuid, name: $this->storage->name); $this->dispatch('success', 'Storage settings updated and connection verified.'); } catch (\Throwable $e) { @@ -209,5 +226,13 @@ class Form extends Component public function updatedEndpointParts(): void { $this->endpointPartsChanged = true; + if (! $this->usePathStyleEndpointChanged) { + $this->usePathStyleEndpoint = S3Storage::detectPathStyleEndpoint(DomainUrlParts::compose(...$this->endpointParts)); + } + } + + public function updatedUsePathStyleEndpoint(): void + { + $this->usePathStyleEndpointChanged = true; } } diff --git a/app/Livewire/SwitchTeam.php b/app/Livewire/SwitchTeam.php index 11d7141537..8e41db6f5c 100644 --- a/app/Livewire/SwitchTeam.php +++ b/app/Livewire/SwitchTeam.php @@ -9,7 +9,10 @@ class SwitchTeam extends Component { public string $selectedTeamId = 'default'; - protected $listeners = ['team-avatar-updated' => '$refresh']; + protected $listeners = [ + 'team-avatar-updated' => '$refresh', + 'team-updated' => '$refresh', + ]; public function mount() { diff --git a/app/Livewire/Team/Index.php b/app/Livewire/Team/Index.php index 62c03d57ed..e5b0e3e6fe 100644 --- a/app/Livewire/Team/Index.php +++ b/app/Livewire/Team/Index.php @@ -147,6 +147,7 @@ class Index extends Component $this->syncData(true); $this->team->save(); refreshSession(); + $this->dispatch('team-updated'); $this->dispatch('success', 'Team updated.'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index 45b0affdd3..eca84e2e1a 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -25,12 +25,7 @@ class Member extends Component || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { throw new \Exception('You are not authorized to perform this action.'); } - $teamId = currentTeam()->id; - DB::transaction(function () use ($teamId): void { - $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]); - RevokeUserTeamTokens::forUserTeam($this->member, $teamId); - }); - $this->auditRoleUpdate($teamId, Role::ADMIN); + $this->updateRole(Role::ADMIN); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -46,12 +41,7 @@ class Member extends Component || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { throw new \Exception('You are not authorized to perform this action.'); } - $teamId = currentTeam()->id; - DB::transaction(function () use ($teamId): void { - $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]); - RevokeUserTeamTokens::forUserTeam($this->member, $teamId); - }); - $this->auditRoleUpdate($teamId, Role::OWNER); + $this->updateRole(Role::OWNER); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -67,12 +57,7 @@ class Member extends Component || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { throw new \Exception('You are not authorized to perform this action.'); } - $teamId = currentTeam()->id; - DB::transaction(function () use ($teamId): void { - $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value, 'access' => null]); - RevokeUserTeamTokens::forUserTeam($this->member, $teamId); - }); - $this->auditRoleUpdate($teamId, Role::MEMBER); + $this->updateRole(Role::MEMBER); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -114,8 +99,20 @@ class Member extends Component return $this->member->teams()->where('teams.id', currentTeam()->id)->first()?->pivot?->role; } - private function auditRoleUpdate(int $teamId, Role $role): void + private function updateRole(Role $role): void { + $teamId = currentTeam()->id; + $previousRole = Role::from($this->getMemberRole()); + DB::transaction(function () use ($teamId, $role, $previousRole): void { + // A member gets the default team-wide access again. + $this->member->teams()->updateExistingPivot($teamId, $role === Role::MEMBER + ? ['role' => $role->value, 'access' => null] + : ['role' => $role->value]); + // A promotion keeps the tokens: the API checks the abilities against the role on each request. + if ($role->lt($previousRole)) { + RevokeUserTeamTokens::forUserTeam($this->member, $teamId); + } + }); auditLog('ui.team_member.role_updated', [ 'team_id' => $teamId, 'member_id' => $this->member->id, diff --git a/app/Models/Application.php b/app/Models/Application.php index 37abf54f30..6e4108f9af 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1586,6 +1586,8 @@ class Application extends BaseModel : 'GIT_SSH_COMMAND="'.$resolvedGitSshCommand.'"') : 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"'; + $git_clone_command = $this->gitLfsSkipSmudgePrefix($onlyCheckout).$git_clone_command; + // Use the explicitly passed commit (e.g. from rollback), falling back to the application's git_commit_sha. // Invalid refs will cause the git checkout/fetch command to fail on the remote server. $commitToUse = $commit ?? $this->git_commit_sha; @@ -1593,9 +1595,18 @@ class Application extends BaseModel if ($commitToUse !== 'HEAD') { $escapedCommit = escapeshellarg($commitToUse); // If shallow clone is enabled and we need a specific commit, - // we need to fetch that specific commit with depth=1 + // we need to fetch that specific commit with depth=1. + // A remote cannot fetch a short SHA (Bitbucket sends one for pull requests), + // so fetch the tip of each branch and let the checkout expand it. When the + // commit is not a branch tip, fetch the full history of the branches. if ($isShallowCloneEnabled) { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && echo 'Fetching the commit.' && {$sshCommand} {$gitCommand} fetch --depth=1 origin {$escapedCommit} && {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; + $checkoutCommand = "{$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; + if (preg_match('/\A[0-9a-fA-F]{7,39}\z/', $commitToUse) === 1) { + $branchesRefspec = escapeshellarg('+refs/heads/*:refs/remotes/origin/*'); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && echo 'Fetching the commit.' && {$sshCommand} {$gitCommand} fetch --depth=1 origin {$branchesRefspec} && ({$checkoutCommand} || (echo 'The commit is not a branch tip. Fetching the full history.' && {$sshCommand} {$gitCommand} fetch --unshallow origin {$branchesRefspec} && {$checkoutCommand}))"; + } else { + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && echo 'Fetching the commit.' && {$sshCommand} {$gitCommand} fetch --depth=1 origin {$escapedCommit} && {$checkoutCommand}"; + } } else { $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } @@ -1866,6 +1877,16 @@ class Application extends BaseModel ]; } + /** + * The helper image installs the Git LFS filter system-wide, so every checkout downloads + * LFS objects unless the smudge filter is skipped. A checkout that only reads files (the + * Compose file) never needs LFS objects. + */ + private function gitLfsSkipSmudgePrefix(bool $onlyCheckout = false): string + { + return $this->settings->is_git_lfs_enabled && ! $onlyCheckout ? '' : 'export GIT_LFS_SKIP_SMUDGE=1 && '; + } + private function gitCommandsAsShellCommand(Collection|array|string $commands): string { if (is_string($commands)) { @@ -1979,10 +2000,11 @@ class Application extends BaseModel $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOption ?? null); $escapedPrBranch = escapeshellarg($branch); + $git_pr_command = "{$this->gitLfsSkipSmudgePrefix($only_checkout)}cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command"; if ($exec_in_docker) { - $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command"))); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_pr_command))); } else { - $commands->push($this->gitCommand("cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command")); + $commands->push($this->gitCommand($git_pr_command)); } } @@ -2266,6 +2288,23 @@ class Application extends BaseModel instant_remote_process($commands, $this->destination->server, false); } + /** + * The services of the Compose file, read without parse(). A parse saves volumes and variables, so + * code that only needs the service names uses this. + * + * @return array + */ + public function composeServicesWithoutParse(): array + { + try { + $services = data_get(parseDockerComposeYaml((string) $this->docker_compose_raw), 'services'); + } catch (\Throwable) { + return []; + } + + return is_array($services) ? $services : []; + } + public function parse(int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null) { if ((int) $this->compose_parsing_version >= 3) { diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index e0d68780a7..93df2afc47 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -42,6 +42,13 @@ class EnvironmentVariable extends BaseModel { use Auditable; + /** + * Flags that a "# coolify:" line of the developer view can set, with their defaults. + * + * @var array + */ + public const DEV_VIEW_FLAG_DEFAULTS = ['literal' => false, 'buildtime' => true, 'runtime' => true]; + public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_']; /** @@ -101,6 +108,7 @@ class EnvironmentVariable extends BaseModel 'value' => 'encrypted', 'preserve_compose_null' => 'boolean', 'is_multiline' => 'boolean', + 'is_literal' => 'boolean', 'is_preview' => 'boolean', 'is_runtime' => 'boolean', 'is_buildtime' => 'boolean', diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 50d5a51578..d96632a683 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -72,6 +72,11 @@ class LocalFileVolume extends BaseModel done SH; + /** + * The pull request whose preview this copy writes. Not saved: see forPullRequest(). + */ + protected int $previewPullRequestId = 0; + protected $casts = [ // 'fs_path' => 'encrypted', // 'mount_path' => 'encrypted', @@ -343,10 +348,17 @@ class LocalFileVolume extends BaseModel validateShellSafePath($path, 'storage path'); $escapedPath = escapeshellarg($path); - $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); - $isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server); + $state = self::remoteFileStates([(string) $path], $server)[0]; + if ($state === 'unknown') { + throw new \Exception("Coolify cannot check the file {$path} on the server."); + } + if ($state === 'other' && ! $this->is_directory) { + throw new \Exception("A symbolic link or a special file is at {$path} on the server. Coolify did not write the file."); + } + $isFile = $state === 'file'; + $isDir = in_array($state, ['directory', 'empty-directory'], true); $replacesEmptyDirectory = false; - if ($isFile === 'OK' && $this->is_directory) { + if ($isFile && $this->is_directory) { if ($this->remoteFileExceedsLimit($escapedPath, $server)) { $this->content = self::TOO_LARGE_PLACEHOLDER; } else { @@ -356,7 +368,7 @@ class LocalFileVolume extends BaseModel $this->save(); FileStorageChanged::dispatch(data_get($server, 'team_id')); throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.'); - } elseif ($isDir === 'OK' && ! $this->is_directory) { + } elseif ($isDir && ! $this->is_directory) { if ($path === '/' || $path === '.' || $path === '..' || $path === '' || str($path)->isEmpty() || is_null($path)) { $this->is_directory = true; $this->save(); @@ -364,12 +376,12 @@ class LocalFileVolume extends BaseModel } // Docker creates a missing bind source as an empty directory. Replace only an empty // directory inside the resource directory; never delete files that are on the server. - if ($isOutsideResourceDirectory || self::remoteFileStates([(string) $path], $server)[0] !== 'empty-directory') { + if ($isOutsideResourceDirectory || $state !== 'empty-directory') { throw new \Exception("The following file is a directory on the server, but you are trying to mark it as a file: {$path}

Please delete the directory on the server or mark it as directory."); } $replacesEmptyDirectory = true; } - if (($isDir === 'NOK' || $replacesEmptyDirectory) && ! $this->is_directory) { + if ((! $isDir || $replacesEmptyDirectory) && ! $this->is_directory) { if ($replacesEmptyDirectory) { $commands->push("rmdir {$escapedPath}"); } @@ -388,7 +400,7 @@ class LocalFileVolume extends BaseModel if ($chmod) { $commands->push('chmod -- '.escapeshellarg($chmod)." {$escapedPath}"); } - } elseif ($isDir === 'NOK' && $this->is_directory) { + } elseif (! $isDir && $this->is_directory) { $commands->push("mkdir -p {$escapedPath} > /dev/null 2>&1 || true"); } @@ -405,22 +417,24 @@ class LocalFileVolume extends BaseModel * front of it and never changes the script. See REMOTE_FILE_STATE_SCRIPT for the output. * * @param list $paths + * @param (Closure(string): string)|null $quote Makes a shell argument of a path. The default is escapeshellarg(). */ - public static function remoteFileStateCommand(array $paths): string + public static function remoteFileStateCommand(array $paths, ?Closure $quote = null): string { - return 'sh -c '.escapeshellarg(self::REMOTE_FILE_STATE_SCRIPT).' sh '.implode(' ', array_map('escapeshellarg', $paths)); + return 'sh -c '.escapeshellarg(self::REMOTE_FILE_STATE_SCRIPT).' sh '.implode(' ', array_map($quote ?? escapeshellarg(...), $paths)); } /** * Gets the state of each path with one server command. A path without a state in the output is `unknown`. * * @param list $paths + * @param (Closure(string): string)|null $quote Makes a shell argument of a path. The default is escapeshellarg(). * @return list */ - public static function remoteFileStates(array $paths, Server $server): array + public static function remoteFileStates(array $paths, Server $server, ?Closure $quote = null): array { $states = []; - $output = (string) instant_remote_process([self::remoteFileStateCommand($paths)], $server, false); + $output = (string) instant_remote_process([self::remoteFileStateCommand($paths, $quote)], $server, false); foreach (preg_split('/\R/', trim($output)) as $line) { if (preg_match('/^(\d+):([a-z-]+)$/', trim($line), $matches)) { $states[(int) $matches[1] - 1] = $matches[2]; @@ -560,16 +574,28 @@ class LocalFileVolume extends BaseModel public function resolvedFsPath(string $workdir): Stringable { $path = trim((string) $this->fs_path); - if (str_starts_with($path, '/') || str_starts_with($path, '~')) { - return str($path); - } - if (str_starts_with($path, '.')) { - $path = substr($path, 1); + if (! str_starts_with($path, '/') && ! str_starts_with($path, '~')) { + if (str_starts_with($path, '.')) { + $path = substr($path, 1); + } + $path = ltrim($path, '/'); + $path = rtrim($workdir, '/').($path === '' ? '' : '/'.$path); } - $path = ltrim($path, '/'); + return str(addPreviewDeploymentSuffix($path, $this->previewPullRequestId)); + } - return str(rtrim($workdir, '/').($path === '' ? '' : '/'.$path)); + /** + * A copy that uses the path of the preview of a pull request, as the Compose parser mounts it + * (`-pr-` unless the PR suffix is off for this storage). The row keeps the path of + * the application, so saving the copy never stores the preview path. + */ + public function forPullRequest(int $pullRequestId): static + { + $copy = clone $this; + $copy->previewPullRequestId = $pullRequestId !== 0 && ($this->is_preview_suffix_enabled ?? true) ? $pullRequestId : 0; + + return $copy; } /** diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 9857b4faf2..6bd9f88322 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -23,6 +23,17 @@ class S3Storage extends BaseModel private const REQUEST_TIMEOUT_SECONDS = 15; + /** + * Endpoint host suffixes of providers that reject path-style URLs: Alibaba Cloud OSS, Tencent Cloud COS + * (buckets created after 2024-01-01), and Volcengine / BytePlus TOS. + */ + private const VIRTUAL_HOSTED_STYLE_HOST_SUFFIXES = ['aliyuncs.com', 'myqcloud.com', 'volces.com', 'bytepluses.com']; + + /** + * Parts of the errors that these providers return for a path-style request. + */ + private const PATH_STYLE_REJECTION_ERRORS = ['secondleveldomainforbidden', 'pathstyledomainforbidden', 'virtual hosted style', 'virtual-hosted style', 'virtual-styled']; + protected $fillable = [ 'team_id', 'name', @@ -32,6 +43,7 @@ class S3Storage extends BaseModel 'secret', 'bucket', 'endpoint', + 'use_path_style_endpoint', 'is_usable', 'unusable_email_sent', ]; @@ -43,6 +55,7 @@ class S3Storage extends BaseModel protected $casts = [ 'is_usable' => 'boolean', + 'use_path_style_endpoint' => 'boolean', 'key' => 'encrypted', 'secret' => 'encrypted', ]; @@ -217,9 +230,7 @@ class S3Storage extends BaseModel throw new \RuntimeException('S3 bucket name is not allowed: '.$validator->errors()->first('bucket')); } - $disk = $this->filesystem(); - // Test the connection by listing files with ListObjectsV2 (S3) - $disk->files(); + $this->listFilesWithAddressingStyleFallback(); $this->unusable_email_sent = false; $this->is_usable = true; @@ -265,7 +276,7 @@ class S3Storage extends BaseModel 'secret' => $this['secret'], 'bucket' => $this['bucket'], 'endpoint' => $this['endpoint'], - 'use_path_style_endpoint' => true, + 'use_path_style_endpoint' => $this->usesPathStyleEndpoint(), 'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint'], $this->trustedInternalHosts()), [ 'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS, 'timeout' => self::REQUEST_TIMEOUT_SECONDS, @@ -282,6 +293,70 @@ class S3Storage extends BaseModel return '-e '.escapeshellarg('MC_REGION='.$this->region); } + /** + * Path-style URLs (`endpoint/bucket`) are the default. Providers like Alibaba Cloud OSS only accept + * virtual-hosted-style URLs (`bucket.endpoint`). A storage that is not saved yet has no value, so it uses the default. + */ + public function usesPathStyleEndpoint(): bool + { + return $this->use_path_style_endpoint ?? true; + } + + /** + * The URL style for a new storage: virtual-hosted style for providers that reject path-style URLs, else path-style. + */ + public static function detectPathStyleEndpoint(?string $endpoint): bool + { + $host = strtolower((string) parse_url((string) $endpoint, PHP_URL_HOST)); + + return ! collect(self::VIRTUAL_HOSTED_STYLE_HOST_SUFFIXES) + ->contains(fn (string $suffix): bool => $host === $suffix || str_ends_with($host, '.'.$suffix)); + } + + /** + * Tests the connection with ListObjectsV2. When the provider rejects path-style URLs, it tests again with + * virtual-hosted-style URLs and keeps that style when it works. + */ + private function listFilesWithAddressingStyleFallback(): void + { + try { + $this->filesystem()->files(); + } catch (\Throwable $exception) { + if (! $this->usesPathStyleEndpoint() || ! self::isPathStyleRejection($exception)) { + throw $exception; + } + + $this->use_path_style_endpoint = false; + try { + $this->filesystem()->files(); + } catch (\Throwable) { + $this->use_path_style_endpoint = true; + + throw $exception; + } + } + } + + private static function isPathStyleRejection(\Throwable $exception): bool + { + for ($current = $exception; $current !== null; $current = $current->getPrevious()) { + if (str($current->getMessage())->lower()->contains(self::PATH_STYLE_REJECTION_ERRORS)) { + return true; + } + } + + return false; + } + + /** + * The `mc alias set` option that makes the MinIO client use virtual-hosted-style URLs, as filesystem() does. + * With path-style URLs, mc keeps its own `auto` lookup, as before. + */ + public function minioClientPathOption(): string + { + return $this->usesPathStyleEndpoint() ? '' : ' --path off'; + } + /** * The bundled MinIO container is a trusted internal S3 target, not a user-supplied webhook destination. * diff --git a/app/Models/Server.php b/app/Models/Server.php index d5ffa27e76..d13f1f468f 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -462,6 +462,20 @@ class Server extends BaseModel static::flushIdentityMap(); } + /** + * Refresh the status of the linked cloud server from its provider. + */ + public function refreshCloudProviderState(): ?string + { + return match ($this->cloudProviderToken?->provider) { + 'hetzner' => $this->hetzner_server_id ? $this->refreshHetznerState() : null, + 'vultr' => $this->vultr_instance_id ? $this->refreshVultrState() : null, + 'digitalocean' => $this->digitalocean_droplet_id ? $this->refreshDigitalOceanState() : null, + 'hostinger' => $this->hostinger_virtual_machine_id ? $this->refreshHostingerState() : null, + default => null, + }; + } + public function refreshHetznerState(): ?string { // Another instance manages the cloud server of an archive; keep the stored status. @@ -2200,7 +2214,7 @@ $siteAddress { */ public function isFluentBitLogDrainEnabled(): bool { - return $this->settings->is_logdrain_newrelic_enabled || $this->settings->is_logdrain_highlight_enabled || $this->settings->is_logdrain_axiom_enabled || $this->settings->is_logdrain_custom_enabled; + return $this->settings->is_logdrain_newrelic_enabled || $this->settings->is_logdrain_axiom_enabled || $this->settings->is_logdrain_custom_enabled; } public function validateOS(): bool|Stringable diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 8a9c599331..f251b7fd5a 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -29,7 +29,6 @@ use OpenApi\Attributes as OA; 'is_logdrain_axiom_enabled' => ['type' => 'boolean'], 'is_logdrain_cloudwatch_enabled' => ['type' => 'boolean'], 'is_logdrain_custom_enabled' => ['type' => 'boolean'], - 'is_logdrain_highlight_enabled' => ['type' => 'boolean'], 'is_logdrain_newrelic_enabled' => ['type' => 'boolean'], 'is_metrics_enabled' => ['type' => 'boolean'], 'is_traffic_analytics_enabled' => ['type' => 'boolean'], @@ -49,12 +48,12 @@ use OpenApi\Attributes as OA; 'is_usable' => ['type' => 'boolean'], 'logdrain_axiom_api_key' => ['type' => 'string'], 'logdrain_axiom_dataset_name' => ['type' => 'string'], + 'logdrain_axiom_domain' => ['type' => 'string', 'description' => 'Base domain of the Axiom edge deployment, for example us-east-1.aws.edge.axiom.co or eu-central-1.aws.edge.axiom.co.'], 'logdrain_cloudwatch_region' => ['type' => 'string'], 'logdrain_cloudwatch_group' => ['type' => 'string'], 'logdrain_cloudwatch_stream_prefix' => ['type' => 'string'], 'logdrain_custom_config' => ['type' => 'string'], 'logdrain_custom_config_parser' => ['type' => 'string'], - 'logdrain_highlight_project_id' => ['type' => 'string'], 'logdrain_newrelic_base_uri' => ['type' => 'string'], 'logdrain_newrelic_license_key' => ['type' => 'string'], 'sentinel_metrics_history_days' => ['type' => 'integer'], @@ -93,6 +92,11 @@ class ServerSetting extends Model public const int DEFAULT_SENTINEL_PUSH_INTERVAL_SECONDS = 60; + /** + * Axiom edge deployment for US East 1. See https://axiom.co/docs/reference/edge-deployments. + */ + public const string DEFAULT_AXIOM_DOMAIN = 'us-east-1.aws.edge.axiom.co'; + protected $fillable = [ 'server_id', 'is_swarm_manager', @@ -109,11 +113,10 @@ class ServerSetting extends Model 'is_logdrain_newrelic_enabled', 'logdrain_newrelic_license_key', 'logdrain_newrelic_base_uri', - 'is_logdrain_highlight_enabled', - 'logdrain_highlight_project_id', 'is_logdrain_axiom_enabled', 'logdrain_axiom_dataset_name', 'logdrain_axiom_api_key', + 'logdrain_axiom_domain', 'is_swarm_worker', 'is_logdrain_custom_enabled', 'logdrain_custom_config', diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index 086cc33e50..e0f40a4542 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -11,6 +11,14 @@ class SharedEnvironmentVariable extends Model { use Auditable; + /** + * Flags that a "# coolify:" line of the developer view can set, with their defaults. + * Shared variables have no buildtime or runtime flags. + * + * @var array + */ + public const DEV_VIEW_FLAG_DEFAULTS = ['literal' => false]; + protected $fillable = [ // Core identification 'key', @@ -40,6 +48,7 @@ class SharedEnvironmentVariable extends Model protected $casts = [ 'key' => 'string', 'value' => 'encrypted', + 'is_literal' => 'boolean', ]; /** diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index d0b0e7a5e1..0c8353e765 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -56,7 +56,7 @@ class StandaloneKeydb extends BaseModel 'health_check_start_period', ]; - protected $appends = ['internal_db_url', 'external_db_url', 'server_status']; + protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; /** * Sensitive fields hidden by default in serialized output (toArray/toJson). diff --git a/app/Models/User.php b/app/Models/User.php index d92f620dcf..a195a0d05c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -174,7 +174,6 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail if ($found_other_member_who_is_not_owner) { $found_other_member_who_is_not_owner->pivot->role = 'owner'; $found_other_member_who_is_not_owner->pivot->save(); - RevokeUserTeamTokens::forUserTeam($found_other_member_who_is_not_owner, $team->id); $team->members()->detach($user->id); } else { static::finalizeTeamDeletion($user, $team); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 21db1febfd..510ffe3b7b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,6 +10,7 @@ use App\Models\Server; use App\Rules\SafeExternalUrl; use App\Services\Access\AccessResolver; use App\Support\AtomicFilesystem; +use App\Support\RetryHttpConnectionFailures; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Console\ConfigCacheCommand; use Illuminate\Foundation\Console\RouteCacheCommand; @@ -45,6 +46,7 @@ class AppServiceProvider extends ServiceProvider $this->configureModels(); $this->configurePasswords(); $this->configureSanctumModel(); + $this->configureHttpClient(); $this->configureGitHubHttp(); $this->configureGitLabHttp(); $this->configureOidcSocialite(); @@ -126,6 +128,12 @@ class AppServiceProvider extends ServiceProvider }); } + private function configureHttpClient(): void + { + Http::globalOptions(['connect_timeout' => 10, 'timeout' => 30]); + Http::globalMiddleware(new RetryHttpConnectionFailures); + } + private function configureGitHubHttp(): void { Http::macro('GitSource', function (string $url) { diff --git a/app/Rules/DockerImageFormat.php b/app/Rules/DockerImageFormat.php index 038cc27619..4c1acf2066 100644 --- a/app/Rules/DockerImageFormat.php +++ b/app/Rules/DockerImageFormat.php @@ -9,6 +9,11 @@ use Illuminate\Translation\PotentiallyTranslatedString; class DockerImageFormat implements ValidationRule { + /** + * @param bool $allowVariableTag Allow $VAR tags. Use it only for images that Docker Compose runs, because only Compose fills in the variables. + */ + public function __construct(private bool $allowVariableTag = false) {} + /** * Run the validation rule. * @@ -35,15 +40,15 @@ class DockerImageFormat implements ValidationRule if (preg_match('/\A(.+)@sha256:([a-f0-9]{64})\z/i', $value, $matches) === 1) { $imageName = $matches[1]; } else { - $lastColon = strrpos($value, ':'); $lastSlash = strrpos($value, '/'); - if ($lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash)) { - $imageName = substr($value, 0, $lastColon); - $tag = substr($value, $lastColon + 1); + $tagColon = strpos($value, ':', $lastSlash === false ? 0 : $lastSlash + 1); + if ($tagColon !== false) { + $imageName = substr($value, 0, $tagColon); + $tag = substr($value, $tagColon + 1); } } - if (! ValidationPatterns::isValidDockerImageName($imageName) || ! ValidationPatterns::isValidDockerImageTag($tag)) { + if (! ValidationPatterns::isValidDockerImageName($imageName) || ! ValidationPatterns::isValidDockerImageTag($tag, allowVariables: $this->allowVariableTag)) { $fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.'); } } diff --git a/app/Services/CloudflareTokenValidator.php b/app/Services/CloudflareTokenValidator.php index a48efda741..265268ccb0 100644 --- a/app/Services/CloudflareTokenValidator.php +++ b/app/Services/CloudflareTokenValidator.php @@ -2,14 +2,13 @@ namespace App\Services; -use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; class CloudflareTokenValidator { public function validate(string $token, array $capabilities): bool { - $client = $this->client($token); + $client = Http::withToken($token)->acceptJson(); $verification = $client->get('https://api.cloudflare.com/client/v4/user/tokens/verify'); if (! $verification->successful() || $verification->json('result.status') !== 'active') { @@ -42,12 +41,4 @@ class CloudflareTokenValidator return true; } - - private function client(string $token): PendingRequest - { - return Http::withToken($token) - ->acceptJson() - ->connectTimeout(5) - ->timeout(10); - } } diff --git a/app/Services/CloudflareTunnelService.php b/app/Services/CloudflareTunnelService.php index 09c2ab72bf..ac25a3b0eb 100644 --- a/app/Services/CloudflareTunnelService.php +++ b/app/Services/CloudflareTunnelService.php @@ -283,6 +283,6 @@ class CloudflareTunnelService private function client(IntegrationToken $token): PendingRequest { - return Http::withToken($token->token)->acceptJson()->connectTimeout(5)->timeout(15); + return Http::withToken($token->token)->acceptJson(); } } diff --git a/app/Services/DigitalOceanService.php b/app/Services/DigitalOceanService.php index da1af942f4..423fc4f0dc 100644 --- a/app/Services/DigitalOceanService.php +++ b/app/Services/DigitalOceanService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Exceptions\CloudProviderApiException; use App\Exceptions\RateLimitException; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; @@ -45,7 +46,7 @@ class DigitalOceanService ); } - throw new \Exception('DigitalOcean API error: '.$response->json('message', 'Unknown error'), $response->status()); + throw new CloudProviderApiException('DigitalOcean', $response->json('message', 'Unknown error'), $response->status(), $response->json('id')); } return $response->json() ?? []; diff --git a/app/Services/Dns/CloudflareDnsProvider.php b/app/Services/Dns/CloudflareDnsProvider.php index 9c7748ff8b..614ab066dd 100644 --- a/app/Services/Dns/CloudflareDnsProvider.php +++ b/app/Services/Dns/CloudflareDnsProvider.php @@ -512,6 +512,6 @@ class CloudflareDnsProvider private function client(IntegrationToken $token): PendingRequest { - return Http::withToken($token->token)->acceptJson()->connectTimeout(5)->timeout(10); + return Http::withToken($token->token)->acceptJson(); } } diff --git a/app/Services/DockerImageParser.php b/app/Services/DockerImageParser.php index b483c979a3..b74a96f7f3 100644 --- a/app/Services/DockerImageParser.php +++ b/app/Services/DockerImageParser.php @@ -20,15 +20,15 @@ class DockerImageParser $this->tag = $matches[2]; $this->isImageHash = true; } else { - // Split by : to handle the tag, but be careful with registry ports - $lastColon = strrpos($imageString, ':'); - $hasSlash = str_contains($imageString, '/'); + // The tag starts at the first colon after the last slash. A colon before + // the last slash is a registry port, and a later colon can be part of a + // ${VAR:-default} tag. + $lastSlash = strrpos($imageString, '/'); + $tagColon = strpos($imageString, ':', $lastSlash === false ? 0 : $lastSlash + 1); - // If the last colon appears after the last slash, it's a tag - // Otherwise it might be a port in the registry URL - if ($lastColon !== false && (! $hasSlash || $lastColon > strrpos($imageString, '/'))) { - $mainPart = substr($imageString, 0, $lastColon); - $this->tag = substr($imageString, $lastColon + 1); + if ($tagColon !== false) { + $mainPart = substr($imageString, 0, $tagColon); + $this->tag = substr($imageString, $tagColon + 1); // Check if the tag is a SHA256 hash $this->isImageHash = $this->isSha256Hash($this->tag); diff --git a/app/Services/DopplerService.php b/app/Services/DopplerService.php index 2513a4f7d8..b9cf24650e 100644 --- a/app/Services/DopplerService.php +++ b/app/Services/DopplerService.php @@ -50,8 +50,6 @@ class DopplerService private function client(): PendingRequest { return Http::withToken($this->token) - ->acceptJson() - ->connectTimeout(5) - ->timeout(10); + ->acceptJson(); } } diff --git a/app/Services/FileDownloadStorageService.php b/app/Services/FileDownloadStorageService.php index f342873c9c..578afb36ae 100644 --- a/app/Services/FileDownloadStorageService.php +++ b/app/Services/FileDownloadStorageService.php @@ -119,7 +119,7 @@ class FileDownloadStorageService 'version' => 'latest', 'region' => $storage->region, 'endpoint' => $storage->endpoint, - 'use_path_style_endpoint' => true, + 'use_path_style_endpoint' => $storage->usesPathStyleEndpoint(), 'credentials' => false, 'retries' => 0, 'http' => array_merge(SafeWebhookUrl::httpClientOptions($storage->endpoint, $storage->trustedInternalHosts()), [ diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index c9675a24c8..10e8ed3ffd 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Exceptions\CloudProviderApiException; use App\Exceptions\RateLimitException; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; @@ -44,7 +45,17 @@ class HetznerService // Exponential backoff for other retriable errors: 100ms, 200ms, 400ms return $attempt * 100; - }) + }, function (\Exception $exception) use ($method) { + if ($exception instanceof RequestException && $exception->response->status() === 429) { + return true; + } + + // A POST that failed with a 5xx or a timeout can still have created the resource, so only idempotent requests are sent again. + // Client errors such as 401, 403, or 422 do not change when the request is sent again. + $isServerError = ! $exception instanceof RequestException || $exception->response->status() >= 500; + + return $isServerError && in_array(strtolower($method), ['get', 'delete'], true); + }, throw: false) ->{$method}($this->baseUrl.$endpoint, $data); if (! $response->successful()) { @@ -61,7 +72,13 @@ class HetznerService ); } - throw new \Exception('Hetzner API error: '.$response->json('error.message', 'Unknown error')); + throw new CloudProviderApiException( + 'Hetzner', + $response->json('error.message', 'Unknown error'), + $response->status(), + $response->json('error.code'), + collect($response->json('error.details.fields', []))->pluck('name')->all(), + ); } return $response->json(); @@ -101,16 +118,31 @@ class HetznerService ]); } - public function getServerTypes(): array + /** + * Server types that can be ordered now. Each type keeps only the locations + * where it is in stock and not deprecated, and types with no such location are removed. + * With $includeOutOfStock, locations where the type is out of stock are kept. + */ + public function getServerTypes(?string $location = null, bool $includeOutOfStock = false): array { $types = $this->requestPaginated('get', '/server_types', 'server_types'); - // Filter out entries where "deprecated" is explicitly true - $filtered = array_filter($types, function ($type) { - return ! (isset($type['deprecated']) && $type['deprecated'] === true); - }); + return collect($types) + ->reject(fn (array $type) => ($type['deprecated'] ?? false) === true) + ->map(function (array $type) use ($includeOutOfStock) { + $type['locations'] = collect($type['locations'] ?? []) + ->filter(fn (array $typeLocation) => empty($typeLocation['deprecation']) + && ($includeOutOfStock || ($typeLocation['available'] ?? true) === true)) + ->values() + ->all(); - return array_values($filtered); + return $type; + }) + ->filter(fn (array $type) => $location === null + ? $type['locations'] !== [] + : in_array($location, array_column($type['locations'], 'name'), true)) + ->values() + ->all(); } public function getSshKeys(): array diff --git a/app/Services/IntegrationTokenValidator.php b/app/Services/IntegrationTokenValidator.php index 2872a83a91..1df5b5f16b 100644 --- a/app/Services/IntegrationTokenValidator.php +++ b/app/Services/IntegrationTokenValidator.php @@ -40,4 +40,11 @@ class IntegrationTokenValidator default => 'The token could not be verified.', }; } + + public function connectionErrorMessage(string $provider): string + { + $name = IntegrationToken::PROVIDER_NAMES[$provider] ?? ucfirst($provider); + + return "Could not connect to the {$name} API. Check the network connection of Coolify and try again."; + } } diff --git a/app/Services/SentinelTrafficClient.php b/app/Services/SentinelTrafficClient.php index 5398456a33..c5545d57a9 100644 --- a/app/Services/SentinelTrafficClient.php +++ b/app/Services/SentinelTrafficClient.php @@ -8,6 +8,7 @@ use App\Data\Traffic\TrafficPathData; use App\Data\Traffic\TrafficSeriesBucketData; use App\Helpers\SshMultiplexingHelper; use App\Models\Server; +use Illuminate\Contracts\Process\InvokedProcess; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; @@ -354,6 +355,56 @@ class SentinelTrafficClient return $this->safeReportedKeys(array_map(fn ($app) => is_array($app) ? ($app['uuid'] ?? null) : null, $bundle['apps'] ?? [])); } + /** + * Fetch the server-wide dashboard bundle of several servers at the same time, so a page + * waits for the slowest server instead of for every server in turn. Each response warms + * the cache that prefetchServerWide() reads; a failed fetch is remembered like in + * fetchDashboard(), so prefetchServerWide() does not fetch it again. + * + * @param iterable $clients + */ + public static function prefetchServerWideConcurrently(iterable $clients, string $from, string $to, string $range, int $pathLimit = 50, int $breakdownLimit = 50, int $appsLimit = 200): void + { + $started = []; + foreach ($clients as $client) { + $url = $client->dashboardUrl(null, $from, $to, $range, $pathLimit, $breakdownLimit, $appsLimit); + if (! $client->fetchesThroughStartRemoteFetch() + || Cache::has($client->cacheKey($url)) + || Cache::get($client->dashboardAbsenceKey()) === true) { + continue; + } + + try { + $started[] = [$client, $url, $client->startRemoteFetch($url)]; + } catch (\Throwable) { + // An unavailable server fails again at once in prefetchServerWide(). + } + } + + foreach ($started as [$client, $url, $process]) { + try { + Cache::put($client->cacheKey($url), $client->guard($client->awaitRemote($process)), 60); + } catch (\Throwable) { + Cache::put($client->dashboardAbsenceKey(), true, 60); + } + } + } + + /** + * False for a fake that serves canned bodies from raw() or remoteFetch(): the concurrent + * prefetch would otherwise reach real SSH. + */ + private function fetchesThroughStartRemoteFetch(): bool + { + foreach (['raw', 'remoteFetch'] as $method) { + if ((new \ReflectionMethod($this, $method))->getDeclaringClass()->getName() !== self::class) { + return false; + } + } + + return true; + } + /** * The usable keys of a Sentinel-reported key list. A key that would be unsafe in a request * url is dropped (and logged) on its own, so it doesn't fail the whole server's analytics. @@ -685,13 +736,21 @@ class SentinelTrafficClient } protected function remoteFetch(string $url): string + { + // Throw the real SSH/docker error (for example, an unreadable SSH key or a missing + // container). A literal "null" body comes back empty; guard() rejects the empty string. + return $this->awaitRemote($this->startRemoteFetch($url)); + } + + /** + * Start the fetch of one URL without waiting for it, so several servers can be fetched at the same time. + */ + protected function startRemoteFetch(string $url): InvokedProcess { $this->ensureRemoteAvailable(); $token = $this->server->settings->ensureValidSentinelToken(); - // Throw the real SSH/docker error (for example, an unreadable SSH key or a missing - // container). A literal "null" body comes back empty; guard() rejects the empty string. - return $this->runRemote($this->buildFetchCommand($token, $url)); + return $this->startRemote($this->buildFetchCommand($token, $url)); } /** @@ -702,7 +761,7 @@ class SentinelTrafficClient $this->ensureRemoteAvailable(); $token = $this->server->settings->ensureValidSentinelToken(); - return $this->runRemote($this->buildBatchCommand($token, $urls)); + return $this->awaitRemote($this->startRemote($this->buildBatchCommand($token, $urls))); } /** @@ -722,14 +781,25 @@ class SentinelTrafficClient /** * Unlike instant_remote_process(), there are no SSH retries; a failure marks the server unavailable. */ - private function runRemote(string $command): string + private function startRemote(string $command): InvokedProcess { $commands = $this->server->isNonRoot() ? parseCommandsByLineForSudo(collect([$command]), $this->server) : [$command]; try { - $process = Process::timeout(self::REMOTE_TIMEOUT) + return Process::timeout(self::REMOTE_TIMEOUT) ->input(SshMultiplexingHelper::sshBatchInput(implode("\n", $commands))) - ->run(SshMultiplexingHelper::generateSshBatchCommand($this->server, commandTimeout: self::REMOTE_TIMEOUT)); + ->start(SshMultiplexingHelper::generateSshBatchCommand($this->server, commandTimeout: self::REMOTE_TIMEOUT)); + } catch (\Throwable $e) { + Cache::put($this->unavailableKey(), true, self::UNAVAILABLE_TTL); + + throw $e; + } + } + + private function awaitRemote(InvokedProcess $invoked): string + { + try { + $process = $invoked->wait(); if ($process->exitCode() !== 0) { excludeCertainErrors($process->errorOutput(), $process->exitCode()); diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index 0edba09cb5..de986a9f57 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -1489,6 +1489,7 @@ class ServerTransferExporter 'secret' => $storage->secret, 'bucket' => $storage->bucket, 'endpoint' => $storage->endpoint, + 'use_path_style_endpoint' => $storage->usesPathStyleEndpoint(), 'is_usable' => (bool) $storage->is_usable, ]; } diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php index 9cdb066eee..7362160471 100644 --- a/app/Services/ServerTransfer/ServerTransferImporter.php +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -2007,6 +2007,7 @@ class ServerTransferImporter 'secret' => data_get($payload, 'secret'), 'bucket' => data_get($payload, 'bucket'), 'endpoint' => data_get($payload, 'endpoint'), + 'use_path_style_endpoint' => (bool) data_get($payload, 'use_path_style_endpoint', true), 'is_usable' => (bool) data_get($payload, 'is_usable', true), ]); $storage->uuid = $uuid; diff --git a/app/Services/VultrService.php b/app/Services/VultrService.php index 347b7e3fbb..ced66a66f7 100644 --- a/app/Services/VultrService.php +++ b/app/Services/VultrService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Exceptions\CloudProviderApiException; use App\Exceptions\RateLimitException; use Illuminate\Support\Facades\Http; @@ -28,7 +29,7 @@ class VultrService ); } - throw new \Exception('Vultr API error: '.$response->json('error', 'Unknown error'), $response->status()); + throw new CloudProviderApiException('Vultr', $response->json('error', 'Unknown error'), $response->status()); } return $response->json() ?? []; diff --git a/app/Support/RetryHttpConnectionFailures.php b/app/Support/RetryHttpConnectionFailures.php new file mode 100644 index 0000000000..1ab42313a0 --- /dev/null +++ b/app/Support/RetryHttpConnectionFailures.php @@ -0,0 +1,47 @@ +send($handler, $request, $options)->otherwise(function (Throwable $exception) use ($handler, $request, $options): PromiseInterface { + if (! $exception instanceof ConnectException || ! in_array($request->getMethod(), self::RETRIED_METHODS, true)) { + return Create::rejectionFor($exception); + } + + Sleep::usleep(500_000); + + return $this->send($handler, $request, $options); + }); + }; + } + + /** + * The cURL handler rejects the promise on a connection failure, but other handlers (such as Http::fake()) throw. + */ + private function send(callable $handler, RequestInterface $request, array $options): PromiseInterface + { + try { + return Create::promiseFor($handler($request, $options)); + } catch (ConnectException $exception) { + return Create::rejectionFor($exception); + } + } +} diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 53bc945c18..77c89f2fbe 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -144,12 +144,23 @@ class ValidationPatterns public const DOCKER_IMAGE_NAME_PATTERN = '/\A(?=.{1,255}\z)(?:(?:(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[0-9a-f:.]+\])(?::[0-9]+)?\/)?[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*)(?:@sha256)?\z/'; /** - * Pattern for Docker image tags. + * Pattern for literal Docker image tags. * * Docker tags may contain letters, digits, underscores, dots, and hyphens, * must start with an alphanumeric/underscore, and are limited to 128 chars. */ - public const DOCKER_IMAGE_TAG_PATTERN = '/\A[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}\z/'; + public const DOCKER_IMAGE_LITERAL_TAG_PATTERN = '/\A[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}\z/'; + + /** + * Pattern for Docker image tags of applications. + * + * Same as DOCKER_IMAGE_LITERAL_TAG_PATTERN, but + * $VAR, ${VAR}, ${VAR:-default} and ${VAR-default} references are also allowed, + * because Docker Compose fills them in from the application's environment + * variables at deploy time. A default may only hold tag characters. Other + * expansions ($(...), ${VAR:?...}, nested references, $1, $$) are rejected. + */ + public const DOCKER_IMAGE_TAG_PATTERN = '/\A(?=.{1,128}\z)(?:[A-Za-z0-9_]|\$(?:[A-Za-z_][A-Za-z0-9_]*+|\{[A-Za-z_][A-Za-z0-9_]*+(?::?-[A-Za-z0-9_.-]*+)?\}))(?:[A-Za-z0-9_.-]|\$(?:[A-Za-z_][A-Za-z0-9_]*+|\{[A-Za-z_][A-Za-z0-9_]*+(?::?-[A-Za-z0-9_.-]*+)?\}))*\z/'; /** * Normalize environment variable keys before validation and storage. @@ -312,7 +323,7 @@ class ValidationPatterns { return [ "{$nameField}.regex" => 'The Docker registry image name must be a valid image repository without a tag and may not contain shell metacharacters.', - "{$tagField}.regex" => 'The Docker registry image tag must be a valid Docker tag and may not contain shell metacharacters.', + "{$tagField}.regex" => 'The Docker registry image tag must be a valid Docker tag or a $VARIABLE reference and may not contain shell metacharacters.', ]; } @@ -348,14 +359,17 @@ class ValidationPatterns /** * Check if a string is a valid Docker image tag. + * + * Set $allowVariables to false where Docker Compose does not fill in the tag, + * for example for images that Coolify runs with docker run. */ - public static function isValidDockerImageTag(?string $value): bool + public static function isValidDockerImageTag(?string $value, bool $allowVariables = true): bool { if (blank($value)) { return true; } - return preg_match(self::DOCKER_IMAGE_TAG_PATTERN, $value) === 1; + return preg_match($allowVariables ? self::DOCKER_IMAGE_TAG_PATTERN : self::DOCKER_IMAGE_LITERAL_TAG_PATTERN, $value) === 1; } /** diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 97a5f87161..78632b762e 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -283,7 +283,7 @@ function deleteBackupsS3(string|array|null $filenames, S3Storage $s3): void 'region' => $s3->region, 'bucket' => $s3->bucket, 'endpoint' => $s3->endpoint, - 'use_path_style_endpoint' => true, + 'use_path_style_endpoint' => $s3->usesPathStyleEndpoint(), 'aws_url' => $s3->awsUrl(), 'throw' => true, ]); diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php index 88d55994dc..176a4e1153 100644 --- a/bootstrap/helpers/domains.php +++ b/bootstrap/helpers/domains.php @@ -1,6 +1,7 @@ $url, 'FQDN' => $host] as $type => $value) { + $application->environment_variables()->updateOrCreate([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => "SERVICE_{$type}_{$serviceEnvKey}", + ], [ + 'value' => $value, + 'is_preview' => false, + ]); + } + } + + $pattern = '/^SERVICE_(URL|FQDN)_'.preg_quote($serviceEnvKey, '/').'(?:_(\d+))?$/'; + $application->environment_variables() + ->where(fn ($query) => $query->where('key', 'LIKE', "SERVICE_URL_{$serviceEnvKey}%") + ->orWhere('key', 'LIKE', "SERVICE_FQDN_{$serviceEnvKey}%")) + ->get() + ->each(function (EnvironmentVariable $variable) use ($pattern, $url, $host) { + if (! preg_match($pattern, $variable->key, $matches)) { + return; + } + $value = $matches[1] === 'URL' ? $url : $host; + if ($value !== null && isset($matches[2])) { + $value = "{$value}:{$matches[2]}"; + } + $variable->update(['value' => $value]); + }); +} diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php index edcbca88f6..04eebdb505 100644 --- a/bootstrap/helpers/github.php +++ b/bootstrap/helpers/github.php @@ -212,7 +212,7 @@ function generateGithubToken(GithubApp $source, string $type) if (! $response->successful()) { $error = data_get($response->json(), 'message', "HTTP {$response->status()}"); if ($error === 'Not Found') { - $error = 'Repository not found. Is it moved or deleted?'; + $error = "GitHub App installation {$source->installation_id} was not found. Install the GitHub App again or update its installation ID."; } throw new RuntimeException("Failed to get installation token for {$source->name} with error: ".$error); } @@ -388,10 +388,9 @@ function loadRepositoryByPage(GithubApp $source, string $token, int $page) ]); $json = $response->json(); if ($response->status() !== 200) { - return [ - 'total_count' => 0, - 'repositories' => [], - ]; + $error = data_get($json, 'message', "HTTP {$response->status()}"); + + throw new RuntimeException("Failed to load repositories for {$source->name} with error: ".$error); } if ($json['total_count'] === 0) { diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 8ae596a1e1..041e56ff0a 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1,7 +1,6 @@ fs_path); + // The file storage keeps the path of the application; a preview deployment adds the suffix (LocalFileVolume::forPullRequest()). + $storedSource = $source; $isPreviewSuffixEnabled = $foundConfig ? (bool) data_get($foundConfig, 'is_preview_suffix_enabled', true) : true; @@ -1617,7 +1618,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'resource_type' => get_class($originalResource), ], [ - 'fs_path' => $source, + 'fs_path' => $storedSource, 'mount_path' => $target, 'content' => $content, 'is_directory' => $isDirectory, @@ -1698,7 +1699,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int ? ['name' => $name] : composeRenamedVolumeDeclarationFor($declaration, $name, $persistentVolume, $isPullRequest)); } - dispatch(new ServerFilesFromServerJob($originalResource)); $volumesParsed->put($index, $volume); } } @@ -2034,7 +2034,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($resource->build_pack === 'dockercompose') { foreach ($domains as $forServiceName => $domain) { $parsedDomain = composeDomainEntryString($domain); - $serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_'); + $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper()->value(); if (filled($parsedDomain)) { $parsedDomain = str($parsedDomain)->explode(',')->first(); @@ -2042,42 +2042,15 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $coolifyScheme = $coolifyUrl->getScheme(); $coolifyFqdn = $coolifyUrl->getHost(); $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); $coolifyEnvironments->put('SERVICE_URL_'.$serviceEnvKey, $coolifyUrl->__toString()); $coolifyEnvironments->put('SERVICE_FQDN_'.$serviceEnvKey, $coolifyFqdn); // A preview uses its own domains; the stored variables belong to the main application. if ($isPullRequest) { continue; } - $resource->environment_variables()->updateOrCreate([ - 'resourceable_type' => Application::class, - 'resourceable_id' => $resource->id, - 'key' => 'SERVICE_URL_'.$serviceEnvKey, - ], [ - 'value' => $coolifyUrl->__toString(), - 'is_preview' => false, - ]); - $resource->environment_variables()->updateOrCreate([ - 'resourceable_type' => Application::class, - 'resourceable_id' => $resource->id, - 'key' => 'SERVICE_FQDN_'.$serviceEnvKey, - ], [ - 'value' => $coolifyFqdn, - 'is_preview' => false, - ]); + syncComposeServiceUrlVariables($resource, $serviceEnvKey, $coolifyUrl->__toString(), $coolifyFqdn); } elseif (! $isPullRequest) { - $resource->environment_variables()->where('resourceable_type', Application::class) - ->where('resourceable_id', $resource->id) - ->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%") - ->update([ - 'value' => null, - ]); - $resource->environment_variables()->where('resourceable_type', Application::class) - ->where('resourceable_id', $resource->id) - ->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%") - ->update([ - 'value' => null, - ]); + syncComposeServiceUrlVariables($resource, $serviceEnvKey, null, null); } } } @@ -3121,7 +3094,6 @@ function serviceParser(Service $resource): Collection ); $topLevel->get('volumes')->put($name, composeRenamedVolumeDeclarationFor($declaration, $name, $persistentVolume)); } - dispatch(new ServerFilesFromServerJob($originalResource)); $volumesParsed->put($index, $volume); } } diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index bfb5f22759..603b4fdc58 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -301,45 +301,53 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli $server = $oneService->service->server; } $fileVolumes = $oneService->fileStorages()->get(); - $escapedWorkdir = escapeshellarg($workdir); - $commands = collect([ - "mkdir -p -- {$escapedWorkdir} > /dev/null 2>&1 || true", - ]); - instant_remote_process($commands, $server); - foreach ($fileVolumes as $fileVolume) { - $content = data_get($fileVolume, 'content'); - // A relative path is inside the resource directory, never in the SSH user's working - // directory. A persisted Compose expression (`${VAR:-/path}`) stays as it is. + if ($isInit) { + instant_remote_process(['mkdir -p -- '.escapeshellarg($workdir).' > /dev/null 2>&1 || true'], $server); + } + if ($fileVolumes->isEmpty()) { + return; + } + // A relative path is inside the resource directory, never in the SSH user's working + // directory. A persisted Compose expression (`${VAR:-/path}`) stays as it is. + $fileLocations = $fileVolumes->map(function (LocalFileVolume $fileVolume) use ($workdir): string { $fsPath = trim((string) $fileVolume->fs_path); - $fileLocation = str_starts_with($fsPath, '$') ? $fsPath : $fileVolume->resolvedFsPath($workdir)->value(); - $escapedFileLocation = filesystemVolumeShellArgument((string) $fileLocation); + + return str_starts_with($fsPath, '$') ? $fsPath : $fileVolume->resolvedFsPath($workdir)->value(); + })->values()->all(); + $states = LocalFileVolume::remoteFileStates($fileLocations, $server, filesystemVolumeShellArgument(...)); + foreach ($fileVolumes->values() as $index => $fileVolume) { + $content = data_get($fileVolume, 'content'); + $fileLocation = $fileLocations[$index]; + $escapedFileLocation = filesystemVolumeShellArgument($fileLocation); // A token with only the write permission can save a Compose file, so Coolify does not // write outside the resource directory here. A deployment or start writes those paths. $isOutsideResourceDirectory = fn (): bool => $fileVolume->isOutsideResourceDirectory(); - // Exists and is a file - $isFile = instant_remote_process(["test -f {$escapedFileLocation} && echo OK || echo NOK"], $server); - // Exists and is a directory - $isDir = instant_remote_process(["test -d {$escapedFileLocation} && echo OK || echo NOK"], $server); + $state = $states[$index]; + // A symbolic link to a directory, a special file, or a failed check: Coolify changes nothing. + if ($state === 'other' || $state === 'unknown') { + continue; + } + $isDir = in_array($state, ['directory', 'empty-directory'], true); - if ($isFile === 'OK') { + if ($state === 'file') { $fileVolume->is_directory = false; $fileVolume->save(); if ($fileVolume->is_based_on_git) { $fileVolume->loadStorageOnServer(); } - } elseif ($isDir === 'OK' && ! $fileVolume->is_directory && filled($content)) { + } elseif ($isDir && ! $fileVolume->is_directory && filled($content)) { // A configured file must not lose its content because a directory is at its path. // Docker leaves an empty directory when it starts before the file exists: replace it. // A directory with files stays; the start or deployment shows a warning for it. - if (! $isOutsideResourceDirectory() && LocalFileVolume::remoteFileStates([(string) $fileLocation], $server)[0] === 'empty-directory') { + if (! $isOutsideResourceDirectory() && $state === 'empty-directory') { $fileVolume->saveStorageOnServer(); } - } elseif ($isDir === 'OK') { + } elseif ($isDir) { // If its a directory & exists $fileVolume->content = null; $fileVolume->is_directory = true; $fileVolume->save(); - } elseif ($isFile === 'NOK' && $isDir === 'NOK' && ! $fileVolume->is_directory && $isInit && $content) { + } elseif ($state === 'missing' && ! $fileVolume->is_directory && $isInit && $content) { // Does not exists (no dir or file), not flagged as directory, is init, has content if ($isOutsideResourceDirectory()) { continue; @@ -352,7 +360,7 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli 'mkdir -p -- "$(dirname -- '.$escapedFileLocation.')"', "echo '$content' | base64 -d | tee -- {$escapedFileLocation}", ], $server); - } elseif ($isFile === 'NOK' && $isDir === 'NOK' && $fileVolume->is_directory && $isInit) { + } elseif ($state === 'missing' && $fileVolume->is_directory && $isInit) { // Does not exists (no dir or file), flagged as directory, is init $fileVolume->content = null; $fileVolume->is_directory = true; @@ -360,7 +368,7 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli if (! $isOutsideResourceDirectory()) { instant_remote_process(["mkdir -p -- {$escapedFileLocation}"], $server); } - } elseif ($isFile === 'NOK' && $isDir === 'NOK' && ! $fileVolume->is_directory && $isInit && is_null($content)) { + } elseif ($state === 'missing' && ! $fileVolume->is_directory && $isInit && is_null($content)) { // Does not exists (no dir or file), not flagged as directory, is init, has no content => create directory $fileVolume->content = null; $fileVolume->is_directory = true; @@ -687,7 +695,7 @@ function clone_service(Service $source, StandaloneDocker|SwarmDocker $destinatio 'created_at', 'updated_at', ])->fill([ - 'name' => $source->name.'-clone-'.$uuid, + 'name' => clone_resource_name($source->name, $uuid), ...$overrides, 'uuid' => $uuid, 'destination_id' => $destination->id, diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index d4e1a03390..cd78aeee34 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -134,6 +134,18 @@ function new_public_id(int $length = 24): string return Str::lower(Str::random($length)); } +/** + * The name of a clone: the source name without the "-clone-" suffixes of earlier clones, then a new suffix. + * Without this, each clone of a clone adds a suffix, and the name soon becomes longer than the 255 characters of the column. + */ +function clone_resource_name(string $name, string $uuid): string +{ + $suffix = '-clone-'.$uuid; + $baseName = preg_replace('/(-clone-[a-z0-9]{7,})+$/i', '', $name); + + return Str::limit($baseName, 255 - strlen($suffix), '').$suffix; +} + /** * Validate that a path or identifier is safe for use in shell commands. * @@ -1258,6 +1270,123 @@ function parseEnvFormatToArray($env_file_contents) return $env_array; } +/** + * Format a value for a KEY=VALUE line of the developer view, so that parseEnvFormatToArray() + * reads it back unchanged. Returns null when the format cannot hold the value: it has a line + * break, or it needs quotes and has both quote types. + */ +function formatEnvValueForDevView(?string $value): ?string +{ + $value ??= ''; + if (str_contains($value, "\n")) { + return null; + } + + $needsQuotes = str_starts_with($value, '"') || str_starts_with($value, "'") || preg_match('/\s#/', $value) === 1; + if (! $needsQuotes) { + return $value; + } + if (! str_contains($value, "'")) { + return "'{$value}'"; + } + if (! str_contains($value, '"')) { + return "\"{$value}\""; + } + + return null; +} + +/** + * Format a variable for the developer view: its KEY=value line, with a "# coolify:" directive + * line above it when a flag is not its default. A variable that the view cannot edit gets a + * placeholder line and no directive. + */ +function formatEnvVariableForDevView(EnvironmentVariable|SharedEnvironmentVariable $variable, bool $canViewValue): string +{ + if (! $canViewValue) { + return "{$variable->key}=(Hidden, only admins can view)"; + } + if ($variable->is_shown_once) { + return "{$variable->key}=(Locked Secret, delete and add again to change)"; + } + if ($variable->is_multiline) { + return "{$variable->key}=(Multiline environment variable, edit in normal view)"; + } + $value = formatEnvValueForDevView($variable->value); + if ($value === null) { + return "{$variable->key}=(Value cannot be shown in this view, edit in normal view)"; + } + + $flags = []; + foreach ($variable::DEV_VIEW_FLAG_DEFAULTS as $flag => $default) { + if ((bool) $variable->{"is_{$flag}"} !== $default) { + $flags[] = $default ? "{$flag}=false" : $flag; + } + } + $line = "{$variable->key}={$value}"; + + return $flags === [] ? $line : '# coolify: '.implode(' ', $flags)."\n".$line; +} + +/** + * Parse developer view text. Returns the result of parseEnvFormatToArray() with a 'settings' + * entry per key: the flags of the "# coolify:" line directly above the key line, as model + * attributes (for example ['is_literal' => true]). A key without a directive gets empty + * settings, so its current flags stay. + * + * @param array $flagDefaults the allowed flags and their defaults + * @return array}> + * + * @throws InvalidArgumentException when a directive is not directly above a KEY=value line, or has an unknown flag or value + */ +function parseEnvDevView(?string $contents, array $flagDefaults): array +{ + $contents ??= ''; + $settingsByKey = []; + $directive = null; + $directiveLine = 0; + + foreach (explode("\n", $contents) as $index => $line) { + $isKeyLine = $line !== '' && ! str_starts_with($line, '#') && str_contains($line, '='); + if ($directive !== null && ! $isKeyLine) { + throw new InvalidArgumentException("Line {$directiveLine}: a \"# coolify:\" line must be directly above a KEY=value line."); + } + + if ($isKeyLine) { + $settingsByKey[substr($line, 0, strpos($line, '='))] = $directive ?? []; + $directive = null; + } elseif (str_starts_with($line, '# coolify:')) { + $directiveLine = $index + 1; + $flags = preg_split('/\s+/', trim(substr($line, strlen('# coolify:'))), -1, PREG_SPLIT_NO_EMPTY); + if ($flags === []) { + throw new InvalidArgumentException("Line {$directiveLine}: the \"# coolify:\" line has no flags."); + } + + $directive = []; + foreach ($flags as $flag) { + [$name, $value] = array_pad(explode('=', $flag, 2), 2, 'true'); + if (! array_key_exists($name, $flagDefaults)) { + throw new InvalidArgumentException("Line {$directiveLine}: unknown flag \"{$name}\". Allowed flags: ".implode(', ', array_keys($flagDefaults)).'.'); + } + if ($value !== 'true' && $value !== 'false') { + throw new InvalidArgumentException("Line {$directiveLine}: the value of \"{$name}\" must be true or false."); + } + $directive["is_{$name}"] = $value === 'true'; + } + } + } + if ($directive !== null) { + throw new InvalidArgumentException("Line {$directiveLine}: a \"# coolify:\" line must be directly above a KEY=value line."); + } + + $variables = parseEnvFormatToArray($contents); + foreach ($variables as $key => $data) { + $variables[$key]['settings'] = $settingsByKey[$key] ?? []; + } + + return $variables; +} + /** * Extract inline comments from environment variables in raw docker-compose YAML. * diff --git a/database/migrations/2026_10_27_142752_add_use_path_style_endpoint_to_s3_storages_table.php b/database/migrations/2026_10_27_142752_add_use_path_style_endpoint_to_s3_storages_table.php new file mode 100644 index 0000000000..1c66f52b18 --- /dev/null +++ b/database/migrations/2026_10_27_142752_add_use_path_style_endpoint_to_s3_storages_table.php @@ -0,0 +1,22 @@ +boolean('use_path_style_endpoint')->default(true); + }); + } + + public function down(): void + { + Schema::table('s3_storages', function (Blueprint $table) { + $table->dropColumn('use_path_style_endpoint'); + }); + } +}; diff --git a/database/migrations/2026_10_27_155023_add_logdrain_axiom_domain_to_server_settings_table.php b/database/migrations/2026_10_27_155023_add_logdrain_axiom_domain_to_server_settings_table.php new file mode 100644 index 0000000000..0bd3cfc8f7 --- /dev/null +++ b/database/migrations/2026_10_27_155023_add_logdrain_axiom_domain_to_server_settings_table.php @@ -0,0 +1,22 @@ +string('logdrain_axiom_domain')->nullable()->default('us-east-1.aws.edge.axiom.co'); + }); + } + + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('logdrain_axiom_domain'); + }); + } +}; diff --git a/database/migrations/2026_10_27_160258_remove_highlight_log_drain_from_server_settings_table.php b/database/migrations/2026_10_27_160258_remove_highlight_log_drain_from_server_settings_table.php new file mode 100644 index 0000000000..681d8e74e1 --- /dev/null +++ b/database/migrations/2026_10_27_160258_remove_highlight_log_drain_from_server_settings_table.php @@ -0,0 +1,43 @@ +where('is_logdrain_highlight_enabled', true)->pluck('server_id'); + Server::query()->whereIn('id', $serverIds)->get() + ->reject(fn (Server $server) => $server->isTransferredAway()) + ->each(fn (Server $server) => StopLogDrain::dispatch($server)); + } catch (Throwable $e) { + Log::warning('Could not queue the removal of Highlight log drain containers: '.$e->getMessage()); + } + + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn(['is_logdrain_highlight_enabled', 'logdrain_highlight_project_id']); + }); + } + + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->boolean('is_logdrain_highlight_enabled')->default(false); + $table->string('logdrain_highlight_project_id')->nullable(); + }); + } +}; diff --git a/docker/coolify-terminal/terminal-server.js b/docker/coolify-terminal/terminal-server.js index 8f34970a9c..96df75dfb7 100755 --- a/docker/coolify-terminal/terminal-server.js +++ b/docker/coolify-terminal/terminal-server.js @@ -1,7 +1,6 @@ import { WebSocketServer } from 'ws'; import http from 'http'; import pty from 'node-pty'; -import { parseCookie } from 'cookie'; import 'dotenv/config'; import { TERMINAL_CLOSE_CODES, @@ -12,6 +11,7 @@ import { extractTargetHost, extractTimeout, detachPtyProcess, + getTerminalAuthHeaders, getTerminalProcessEnv, getTerminalSessionTimeout, isAttachCommand, @@ -111,34 +111,22 @@ const server = http.createServer((req, res) => { } }); -const getSessionCookie = (req) => { - const cookies = parseCookie(req.headers.cookie || ''); - const xsrfToken = cookies['XSRF-TOKEN']; - const appName = process.env.APP_NAME || 'laravel'; - const sessionCookieName = `${appName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}_session`; - return { - sessionCookieName, - xsrfToken: xsrfToken, - laravelSession: cookies[sessionCookieName] - } -} - const verifyClient = async (req) => { - const sessionCookie = getSessionCookie(req); + const authHeaderCandidates = getTerminalAuthHeaders(req.headers.cookie); const requestContext = { remoteAddress: req.socket?.remoteAddress, origin: req.headers.origin, - sessionCookieName: sessionCookie.sessionCookieName, - hasXsrfToken: Boolean(sessionCookie.xsrfToken), - hasLaravelSession: Boolean(sessionCookie.laravelSession), + xsrfTokenCount: authHeaderCandidates.length, }; logTerminal('log', 'Verifying websocket client.', requestContext); // Authenticate with Laravel backend - const result = await authenticateTerminalUpgrade(sessionCookie, postToCoolify); + const result = await authenticateTerminalUpgrade(authHeaderCandidates, postToCoolify); if (result.authenticated) { + // The connection handler sends its requests with the XSRF token that Coolify accepted. + req.terminalAuthHeaders = result.authHeaders; logTerminal('log', 'Websocket client authentication succeeded.', requestContext); } else if (result.error) { logTerminal('error', 'Websocket client authentication failed.', { @@ -170,7 +158,7 @@ wss.on('connection', async (ws, req) => { const userId = generateUserId(); ws.userId = userId; - const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); + const authHeaders = req.terminalAuthHeaders; const userSession = { ws, userId, @@ -180,17 +168,12 @@ wss.on('connection', async (ws, req) => { authReady: false, pendingMessages: [], terminalSessionTimer: null, - authHeaders: { - 'Cookie': `${sessionCookieName}=${laravelSession}`, - 'X-XSRF-TOKEN': xsrfToken, - }, + authHeaders, }; const connectionContext = { userId, remoteAddress: req.socket?.remoteAddress, - sessionCookieName, - hasXsrfToken: Boolean(xsrfToken), - hasLaravelSession: Boolean(laravelSession), + hasAuthTokens: Boolean(authHeaders), }; // Register socket handlers up front so messages sent immediately by the client @@ -214,17 +197,14 @@ wss.on('connection', async (ws, req) => { }); // Verify presence of required tokens - if (!laravelSession || !xsrfToken) { + if (!authHeaders) { logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext); rejectTerminalSocket(ws, TERMINAL_CLOSE_CODES.AUTH_REJECTED, 'Unauthorized: Missing required tokens'); return; } try { - const response = await postToCoolify('/terminal/auth/ips', { - 'Cookie': `${sessionCookieName}=${laravelSession}`, - 'X-XSRF-TOKEN': xsrfToken - }); + const response = await postToCoolify('/terminal/auth/ips', authHeaders); if (response.status !== 200) { throw createHttpError(response); @@ -464,13 +444,12 @@ async function handleCommand(ws, command, userId) { }, terminalSessionTimeout * 1000); } -// The text message is kept for clients that predate the close code. function rejectTerminalToken(userSession, message) { logTerminal('warn', 'Closing websocket connection because the terminal token was rejected.', { userId: userSession.userId, reason: message, }); - rejectTerminalSocket(userSession.ws, TERMINAL_CLOSE_CODES.TOKEN_REJECTED, message, { message }); + rejectTerminalSocket(userSession.ws, TERMINAL_CLOSE_CODES.TOKEN_REJECTED, message); } async function handleTerminalToken(userSession, token) { diff --git a/docker/coolify-terminal/terminal-utils.js b/docker/coolify-terminal/terminal-utils.js index 8675e10516..ac2325f0da 100644 --- a/docker/coolify-terminal/terminal-utils.js +++ b/docker/coolify-terminal/terminal-utils.js @@ -1,3 +1,5 @@ +import { parseCookie } from 'cookie'; + export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60; /** @@ -14,16 +16,54 @@ export const TERMINAL_CLOSE_CODES = Object.freeze({ export const TERMINAL_REJECTED_SOCKET_TERMINATE_MS = 2000; /** - * Authenticates a terminal WebSocket upgrade with the browser's Laravel session - * cookie and XSRF token. Performs the same checks as the former verifyClient - * callback; only the way a rejection is reported to the client differs. - * - * @param {{ laravelSession?: string, xsrfToken?: string, sessionCookieName: string }} session - * @param {(path: string, headers: object) => Promise<{ status: number }>} postToCoolify - * @returns {Promise<{ authenticated: true } | { authenticated: false, closeCode: number, reason: string, status?: number, error?: Error }>} + * Limits the Coolify requests that one WebSocket upgrade can cause. */ -export async function authenticateTerminalUpgrade({ laravelSession, xsrfToken, sessionCookieName }, postToCoolify) { - if (!laravelSession || !xsrfToken) { +export const MAX_XSRF_TOKEN_CANDIDATES = 5; + +/** + * Builds the header sets that can authenticate terminal requests to Coolify, one + * for each XSRF-TOKEN cookie. The whole browser Cookie header is forwarded, so + * Laravel finds its session cookie by its own name (SESSION_COOKIE or the slug + * of APP_NAME). + * + * The browser sends more than one XSRF-TOKEN cookie when a Laravel app on a + * parent domain also sets one (for example SESSION_DOMAIN=.example.com). Their + * order in the header does not show which one is Coolify's, so the caller must + * try each one. + * + * @param {string | undefined} cookieHeader + * @returns {Array<{ 'Cookie': string, 'X-XSRF-TOKEN': string }>} empty when a required value is missing + */ +export function getTerminalAuthHeaders(cookieHeader) { + if (!cookieHeader) { + return []; + } + + // parseCookie() keeps only the first cookie of a name, so parse each pair alone. + const xsrfTokens = cookieHeader + .split(';') + .map((pair) => parseCookie(pair)['XSRF-TOKEN']) + .filter(Boolean); + + return [...new Set(xsrfTokens)] + .slice(0, MAX_XSRF_TOKEN_CANDIDATES) + .map((xsrfToken) => ({ + 'Cookie': cookieHeader, + 'X-XSRF-TOKEN': xsrfToken, + })); +} + +/** + * Authenticates a terminal WebSocket upgrade with the browser's Laravel session + * cookie and XSRF token. Returns the headers that Coolify accepted, for the + * requests that follow on the same connection. + * + * @param {Array<{ 'Cookie': string, 'X-XSRF-TOKEN': string }>} authHeaderCandidates from getTerminalAuthHeaders() + * @param {(path: string, headers: object) => Promise<{ status: number }>} postToCoolify + * @returns {Promise<{ authenticated: true, authHeaders: object } | { authenticated: false, closeCode: number, reason: string, status?: number, error?: Error }>} + */ +export async function authenticateTerminalUpgrade(authHeaderCandidates, postToCoolify) { + if (authHeaderCandidates.length === 0) { return { authenticated: false, closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED, @@ -31,22 +71,18 @@ export async function authenticateTerminalUpgrade({ laravelSession, xsrfToken, s }; } + let status; + try { - const response = await postToCoolify('/terminal/auth', { - 'Cookie': `${sessionCookieName}=${laravelSession}`, - 'X-XSRF-TOKEN': xsrfToken, - }); + for (const authHeaders of authHeaderCandidates) { + const response = await postToCoolify('/terminal/auth', authHeaders); - if (response.status === 200) { - return { authenticated: true }; + if (response.status === 200) { + return { authenticated: true, authHeaders }; + } + + status = response.status; } - - return { - authenticated: false, - closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED, - reason: 'Unauthorized: Invalid credentials', - status: response.status, - }; } catch (error) { return { authenticated: false, @@ -55,14 +91,20 @@ export async function authenticateTerminalUpgrade({ laravelSession, xsrfToken, s error, }; } + + return { + authenticated: false, + closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED, + reason: 'Unauthorized: Invalid credentials', + status, + }; } /** - * Closes a WebSocket that must not be used, optionally sending a legacy text - * message first for clients that predate close codes. The socket is terminated - * if the peer does not complete the close handshake quickly. + * Closes a WebSocket that must not be used. The socket is terminated if the + * peer does not complete the close handshake quickly. */ -export function rejectTerminalSocket(ws, closeCode, reason, { message = null, terminateAfterMs = TERMINAL_REJECTED_SOCKET_TERMINATE_MS } = {}) { +export function rejectTerminalSocket(ws, closeCode, reason, { terminateAfterMs = TERMINAL_REJECTED_SOCKET_TERMINATE_MS } = {}) { const terminateTimer = setTimeout(() => ws.terminate(), terminateAfterMs); terminateTimer.unref?.(); ws.once('close', () => clearTimeout(terminateTimer)); @@ -70,10 +112,6 @@ export function rejectTerminalSocket(ws, closeCode, reason, { message = null, te // malformed frame from an unauthenticated client) would crash the server. ws.on('error', () => ws.terminate()); - if (message !== null) { - ws.send(message); - } - ws.close(closeCode, reason); } diff --git a/docker/coolify-terminal/terminal-utils.test.js b/docker/coolify-terminal/terminal-utils.test.js index d0452e1f72..48f628707d 100644 --- a/docker/coolify-terminal/terminal-utils.test.js +++ b/docker/coolify-terminal/terminal-utils.test.js @@ -6,6 +6,7 @@ import { authenticateTerminalUpgrade, createTerminalUpgradeHandler, extractSshArgs, + getTerminalAuthHeaders, extractTargetHost, getTerminalProcessEnv, getTerminalSessionTimeout, @@ -308,18 +309,40 @@ function createFakeWebSocketServer({ handles = true } = {}) { const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); -test('authenticateTerminalUpgrade rejects missing session cookies with the auth close code without calling Coolify', async () => { +test('getTerminalAuthHeaders forwards the whole cookie header so Laravel reads its own session cookie name', () => { + // APP_NAME="Coolify - Prod" gives the Laravel cookie "coolify_prod_session"; + // the terminal server must not guess that name (issue #10549). + const cookieHeader = 'theme=dark; coolify_prod_session=session-value; XSRF-TOKEN=xsrf-value'; + + assert.deepEqual(getTerminalAuthHeaders(cookieHeader), [{ + 'Cookie': cookieHeader, + 'X-XSRF-TOKEN': 'xsrf-value', + }]); +}); + +test('getTerminalAuthHeaders makes one candidate for each XSRF-TOKEN cookie', () => { + // A Laravel app on a sibling subdomain with SESSION_DOMAIN=.example.com sets a + // second XSRF-TOKEN cookie that the browser also sends to Coolify (issue #10397). + const cookieHeader = 'XSRF-TOKEN=sibling%3D; coolify_session=session; XSRF-TOKEN=coolify%3D; XSRF-TOKEN=sibling%3D'; + + assert.deepEqual(getTerminalAuthHeaders(cookieHeader).map((headers) => headers['X-XSRF-TOKEN']), ['sibling=', 'coolify=']); +}); + +test('getTerminalAuthHeaders limits the number of XSRF-TOKEN candidates', () => { + const cookieHeader = Array.from({ length: 50 }, (_, index) => `XSRF-TOKEN=token-${index}`).join('; '); + + assert.deepEqual(getTerminalAuthHeaders(cookieHeader).map((headers) => headers['X-XSRF-TOKEN']), ['token-0', 'token-1', 'token-2', 'token-3', 'token-4']); +}); + +test('authenticateTerminalUpgrade rejects missing cookies with the auth close code without calling Coolify', async () => { let requests = 0; const postToCoolify = async () => { requests++; return { status: 200 }; }; - for (const session of [ - { sessionCookieName: 'coolify_session', laravelSession: undefined, xsrfToken: 'xsrf' }, - { sessionCookieName: 'coolify_session', laravelSession: 'session', xsrfToken: undefined }, - ]) { - assert.deepEqual(await authenticateTerminalUpgrade(session, postToCoolify), { + for (const cookieHeader of ['', undefined, 'coolify_session=session', 'coolify_session=session; XSRF-TOKEN=']) { + assert.deepEqual(await authenticateTerminalUpgrade(getTerminalAuthHeaders(cookieHeader), postToCoolify), { authenticated: false, closeCode: TERMINAL_CLOSE_CODES.AUTH_REJECTED, reason: 'Unauthorized: Missing required tokens', @@ -334,36 +357,50 @@ test('authenticateTerminalUpgrade rejects missing session cookies with the auth test('authenticateTerminalUpgrade checks the Laravel session exactly like the previous verifyClient', async () => { const requests = []; const result = await authenticateTerminalUpgrade( - { sessionCookieName: 'coolify_session', laravelSession: 'session-value', xsrfToken: 'xsrf-value' }, + getTerminalAuthHeaders('coolify_session=session-value; XSRF-TOKEN=xsrf-value'), async (path, headers) => { requests.push([path, headers]); return { status: 200 }; }, ); - assert.deepEqual(result, { authenticated: true }); - assert.deepEqual(requests, [[ - '/terminal/auth', - { 'Cookie': 'coolify_session=session-value', 'X-XSRF-TOKEN': 'xsrf-value' }, - ]]); + const authHeaders = { 'Cookie': 'coolify_session=session-value; XSRF-TOKEN=xsrf-value', 'X-XSRF-TOKEN': 'xsrf-value' }; + assert.deepEqual(result, { authenticated: true, authHeaders }); + assert.deepEqual(requests, [['/terminal/auth', authHeaders]]); +}); + +test('authenticateTerminalUpgrade tries the next XSRF-TOKEN when Coolify rejects one', async () => { + const triedTokens = []; + const result = await authenticateTerminalUpgrade( + getTerminalAuthHeaders('XSRF-TOKEN=sibling; coolify_session=session; XSRF-TOKEN=coolify; XSRF-TOKEN=other'), + async (path, headers) => { + triedTokens.push(headers['X-XSRF-TOKEN']); + return { status: headers['X-XSRF-TOKEN'] === 'coolify' ? 200 : 419 }; + }, + ); + + assert.equal(result.authenticated, true); + assert.equal(result.authHeaders['X-XSRF-TOKEN'], 'coolify'); + assert.deepEqual(triedTokens, ['sibling', 'coolify']); }); test('authenticateTerminalUpgrade rejects every non-200 Coolify response', async () => { for (const status of [201, 204, 302, 401, 403, 419, 500]) { const result = await authenticateTerminalUpgrade( - { sessionCookieName: 'coolify_session', laravelSession: 'session', xsrfToken: 'xsrf' }, + getTerminalAuthHeaders('coolify_session=session; XSRF-TOKEN=xsrf; XSRF-TOKEN=other'), async () => ({ status }), ); assert.equal(result.authenticated, false, `status ${status} must not authenticate`); assert.equal(result.closeCode, TERMINAL_CLOSE_CODES.AUTH_REJECTED); assert.equal(result.reason, 'Unauthorized: Invalid credentials'); + assert.equal(result.status, status); } }); test('authenticateTerminalUpgrade does not authenticate when Coolify is unreachable', async () => { const result = await authenticateTerminalUpgrade( - { sessionCookieName: 'coolify_session', laravelSession: 'session', xsrfToken: 'xsrf' }, + getTerminalAuthHeaders('coolify_session=session; XSRF-TOKEN=xsrf'), async () => { throw new Error('connect ECONNREFUSED'); }, @@ -374,18 +411,13 @@ test('authenticateTerminalUpgrade does not authenticate when Coolify is unreacha assert.equal(result.error.message, 'connect ECONNREFUSED'); }); -test('rejectTerminalSocket sends the legacy message before closing with a readable code', () => { +test('rejectTerminalSocket closes with a readable code and sends no text message', () => { const ws = createFakeWebSocket(); - rejectTerminalSocket(ws, TERMINAL_CLOSE_CODES.TOKEN_REJECTED, 'Unauthorized: Terminal token was rejected', { - message: 'Unauthorized: Terminal token was rejected', - }); + rejectTerminalSocket(ws, TERMINAL_CLOSE_CODES.TOKEN_REJECTED, 'Unauthorized: Terminal token was rejected'); ws.peerClosed(); - assert.deepEqual(ws.events, [ - ['send', 'Unauthorized: Terminal token was rejected'], - ['close', 4403, 'Unauthorized: Terminal token was rejected'], - ]); + assert.deepEqual(ws.events, [['close', 4403, 'Unauthorized: Terminal token was rejected']]); }); test('rejectTerminalSocket terminates peers that ignore the close handshake', async () => { diff --git a/docs/server-transfer-full-coverage.md b/docs/server-transfer-full-coverage.md index 3c39e0e15d..25a7835e03 100644 --- a/docs/server-transfer-full-coverage.md +++ b/docs/server-transfer-full-coverage.md @@ -42,7 +42,7 @@ When you add a model or a setting to the transfer, add it to this file too. | ✅ | Metrics / Sentinel | metrics on, refresh rate 10 s, history 14 days, push interval 30 s | | ✅ | Traffic analytics | on, top N 20, sample threshold, retention 1h=3 / 1d=60 days, IP mode `anonymized` | | ✅ | GeoIP | on, MaxMind license key, refresh 14 days | -| ✅ | Log drains | New Relic (key + base URI), Highlight (project ID), Axiom (dataset + key), custom (config + parser), one of them enabled | +| ✅ | Log drains | New Relic (key + base URI), Axiom (dataset, key + edge domain), custom (config + parser), one of them enabled | | ✅ | Cloudflare tunnel | tunnel on, tunnel ID, account ID, Cloudflare integration token selected, and the DNS record of the SSH hostname. Expected: the record moves with the server; the source keeps it only for a take back (not owned) | | ➕ | Jump server flag | on (on a server that is only a jump host) | diff --git a/openapi.json b/openapi.json index 8346937218..53fba3b4c6 100644 --- a/openapi.json +++ b/openapi.json @@ -534,8 +534,6 @@ "required": [ "project_uuid", "server_uuid", - "environment_name", - "environment_uuid", "git_repository", "git_branch", "build_pack" @@ -1197,8 +1195,6 @@ "required": [ "project_uuid", "server_uuid", - "environment_name", - "environment_uuid", "github_app_uuid", "git_repository", "git_branch", @@ -1855,8 +1851,6 @@ "required": [ "project_uuid", "server_uuid", - "environment_name", - "environment_uuid", "private_key_uuid", "git_repository", "git_branch", @@ -2513,8 +2507,6 @@ "required": [ "project_uuid", "server_uuid", - "environment_name", - "environment_uuid", "dockerfile" ], "properties": { @@ -3056,8 +3048,6 @@ "required": [ "project_uuid", "server_uuid", - "environment_name", - "environment_uuid", "docker_registry_image_name" ], "properties": { @@ -4373,7 +4363,7 @@ { "name": "service_name", "in": "query", - "description": "Return logs only from the container of the Docker Compose service with this name. Returns 404 when no running container matches.", + "description": "Return logs only from the container of the Docker Compose service with this name. Returns 404 when no container matches. Stopped containers are included.", "required": false, "schema": { "type": "string" @@ -4477,7 +4467,7 @@ { "name": "service_name", "in": "query", - "description": "Return logs only from the container of the Docker Compose service with this name. Returns 404 when no running container matches.", + "description": "Return logs only from the container of the Docker Compose service with this name. Returns 404 when no container matches. Stopped containers are included.", "required": false, "schema": { "type": "string" @@ -7052,9 +7042,11 @@ "content": { "application\/json": { "schema": { - "type": "string" - }, - "example": "Content is very complex. Will be implemented later." + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Database" + } + } } } }, @@ -7298,13 +7290,12 @@ ], "responses": { "200": { - "description": "Get all databases", + "description": "Get database by UUID", "content": { "application\/json": { "schema": { - "type": "string" - }, - "example": "Content is very complex. Will be implemented later." + "$ref": "#\/components\/schemas\/Database" + } } } }, @@ -7920,9 +7911,7 @@ "schema": { "required": [ "server_uuid", - "project_uuid", - "environment_name", - "environment_uuid" + "project_uuid" ], "properties": { "server_uuid": { @@ -8075,9 +8064,7 @@ "schema": { "required": [ "server_uuid", - "project_uuid", - "environment_name", - "environment_uuid" + "project_uuid" ], "properties": { "server_uuid": { @@ -8214,9 +8201,7 @@ "schema": { "required": [ "server_uuid", - "project_uuid", - "environment_name", - "environment_uuid" + "project_uuid" ], "properties": { "server_uuid": { @@ -8349,9 +8334,7 @@ "schema": { "required": [ "server_uuid", - "project_uuid", - "environment_name", - "environment_uuid" + "project_uuid" ], "properties": { "server_uuid": { @@ -8488,9 +8471,7 @@ "schema": { "required": [ "server_uuid", - "project_uuid", - "environment_name", - "environment_uuid" + "project_uuid" ], "properties": { "server_uuid": { @@ -8627,9 +8608,7 @@ "schema": { "required": [ "server_uuid", - "project_uuid", - "environment_name", - "environment_uuid" + "project_uuid" ], "properties": { "server_uuid": { @@ -8778,9 +8757,7 @@ "schema": { "required": [ "server_uuid", - "project_uuid", - "environment_name", - "environment_uuid" + "project_uuid" ], "properties": { "server_uuid": { @@ -8929,9 +8906,7 @@ "schema": { "required": [ "server_uuid", - "project_uuid", - "environment_name", - "environment_uuid" + "project_uuid" ], "properties": { "server_uuid": { @@ -9068,9 +9043,7 @@ "schema": { "required": [ "server_uuid", - "project_uuid", - "environment_name", - "environment_uuid" + "project_uuid" ], "properties": { "server_uuid": { @@ -11576,6 +11549,9 @@ }, "429": { "description": "DigitalOcean rate limit exceeded." + }, + "502": { + "$ref": "#\/components\/responses\/502" } }, "security": [ @@ -12614,6 +12590,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "502": { + "$ref": "#\/components\/responses\/502" } }, "security": [ @@ -12629,9 +12608,18 @@ "Hetzner" ], "summary": "Get Hetzner Server Types", - "description": "Get all available Hetzner server types (instance sizes).", + "description": "Get the Hetzner server types (instance sizes) that can be ordered now. Each type lists only the locations where it is in stock and not deprecated.", "operationId": "get-hetzner-server-types", "parameters": [ + { + "name": "location", + "in": "query", + "description": "Return only the server types that can be ordered in this location, for example nbg1.", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "cloud_provider_token_uuid", "in": "query", @@ -12725,6 +12713,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "502": { + "$ref": "#\/components\/responses\/502" } }, "security": [ @@ -12805,6 +12796,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "502": { + "$ref": "#\/components\/responses\/502" } }, "security": [ @@ -12876,6 +12870,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "502": { + "$ref": "#\/components\/responses\/502" } }, "security": [ @@ -12941,6 +12938,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "502": { + "$ref": "#\/components\/responses\/502" } }, "security": [ @@ -13009,6 +13009,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "502": { + "$ref": "#\/components\/responses\/502" } }, "security": [ @@ -13118,7 +13121,7 @@ "instant_validate": { "type": "boolean", "example": false, - "description": "Validate server immediately after creation" + "description": "Validate the server and install Docker when it is running. Defaults to true for tokens with the deploy permission, and needs that permission." } }, "type": "object" @@ -13169,6 +13172,9 @@ }, "429": { "$ref": "#\/components\/responses\/429" + }, + "502": { + "$ref": "#\/components\/responses\/502" } }, "security": [ @@ -14671,13 +14677,49 @@ "operationId": "list-resources", "responses": { "200": { - "description": "Get all resources", + "description": "Get all applications, services and standalone databases. The other fields of each resource are the fields of the Application, Service or Database schema.", "content": { "application\/json": { "schema": { - "type": "string" - }, - "example": "Content is very complex. Will be implemented later." + "type": "array", + "items": { + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "application, service, or standalone- for databases (for example standalone-postgresql).", + "type": "string" + }, + "status": { + "description": "For example running:healthy or exited:unhealthy.", + "type": "string" + }, + "environment_id": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "type": "object", + "additionalProperties": true + } + } } } }, @@ -14731,6 +14773,9 @@ "region": { "type": "string" }, + "use_path_style_endpoint": { + "type": "boolean" + }, "is_usable": { "type": "boolean" }, @@ -14810,6 +14855,10 @@ "example": "us-east-1", "description": "S3 region." }, + "use_path_style_endpoint": { + "type": "boolean", + "description": "Use path-style URLs (endpoint\/bucket). Set to false for providers that only accept virtual-hosted-style URLs (bucket.endpoint). Default: false for Alibaba Cloud OSS, Tencent Cloud COS, and Volcengine TOS endpoints, true for all other endpoints." + }, "key": { "type": "string", "description": "Access key." @@ -14908,6 +14957,9 @@ "region": { "type": "string" }, + "use_path_style_endpoint": { + "type": "boolean" + }, "is_usable": { "type": "boolean" }, @@ -15033,6 +15085,10 @@ "type": "string", "description": "S3 region." }, + "use_path_style_endpoint": { + "type": "boolean", + "description": "Use path-style URLs (endpoint\/bucket). Set to false for providers that only accept virtual-hosted-style URLs (bucket.endpoint), such as Alibaba Cloud OSS." + }, "key": { "type": "string", "description": "Access key." @@ -17136,6 +17192,12 @@ "null" ] }, + "logdrain_axiom_domain": { + "type": [ + "string", + "null" + ] + }, "logdrain_axiom_api_key": { "description": "Only present with read:sensitive.", "type": "string" @@ -17234,6 +17296,10 @@ "logdrain_axiom_dataset_name": { "type": "string" }, + "logdrain_axiom_domain": { + "description": "Base domain of the Axiom edge deployment. Default: us-east-1.aws.edge.axiom.co. Use eu-central-1.aws.edge.axiom.co for EU datasets.", + "type": "string" + }, "logdrain_axiom_api_key": { "type": "string" }, @@ -20247,9 +20313,7 @@ "schema": { "required": [ "server_uuid", - "project_uuid", - "environment_name", - "environment_uuid" + "project_uuid" ], "properties": { "type": { @@ -24008,6 +24072,9 @@ }, "429": { "description": "Vultr API rate limit exceeded." + }, + "502": { + "$ref": "#\/components\/responses\/502" } }, "security": [ @@ -24204,6 +24271,400 @@ }, "type": "object" }, + "Database": { + "description": "Standalone database. The fields that start with an engine name (for example postgres_ or redis_) are only returned for that engine. Passwords, connection URLs and init_scripts are only returned for tokens with the read:sensitive or root ability.", + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "database_type": { + "type": "string", + "enum": [ + "standalone-postgresql", + "standalone-redis", + "standalone-mongodb", + "standalone-mysql", + "standalone-mariadb", + "standalone-keydb", + "standalone-dragonfly", + "standalone-clickhouse", + "standalone-sqlite" + ] + }, + "image": { + "type": "string" + }, + "status": { + "description": "Container status and health, for example running:healthy or exited:unhealthy.", + "type": "string" + }, + "server_status": { + "description": "True when the server of the database is reachable and usable.", + "type": "boolean" + }, + "environment_id": { + "type": "integer" + }, + "environment": { + "$ref": "#\/components\/schemas\/Environment", + "description": "Only returned by GET \/databases\/{uuid}. Includes the project." + }, + "destination_id": { + "type": "integer" + }, + "destination_type": { + "type": "string" + }, + "destination": { + "description": "Docker network destination of the database, with its server.", + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "network": { + "type": "string" + }, + "server_id": { + "type": "integer" + }, + "server": { + "$ref": "#\/components\/schemas\/Server" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "type": "object" + }, + "is_public": { + "description": "Not returned for SQLite.", + "type": "boolean" + }, + "public_port": { + "description": "Not returned for SQLite.", + "type": [ + "integer", + "null" + ] + }, + "public_port_timeout": { + "description": "Proxy timeout of the public port in seconds. Not returned for SQLite.", + "type": [ + "integer", + "null" + ] + }, + "ports_mappings": { + "description": "Comma-separated host:container port mappings. Not returned for SQLite.", + "type": [ + "string", + "null" + ] + }, + "enable_ssl": { + "description": "Not returned for ClickHouse and SQLite.", + "type": "boolean" + }, + "ssl_mode": { + "description": "PostgreSQL, MongoDB and MySQL only.", + "type": [ + "string", + "null" + ] + }, + "internal_db_url": { + "description": "Connection URL inside the Docker network. Contains the password. Not returned for SQLite.", + "type": [ + "string", + "null" + ] + }, + "external_db_url": { + "description": "Public connection URL, when the database is public. Contains the password. Not returned for SQLite.", + "type": [ + "string", + "null" + ] + }, + "init_scripts": { + "description": "PostgreSQL only.", + "type": [ + "array", + "null" + ], + "items": { + "properties": { + "index": { + "type": "integer" + }, + "filename": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "type": "object" + } + }, + "custom_docker_run_options": { + "type": [ + "string", + "null" + ] + }, + "is_log_drain_enabled": { + "type": "boolean" + }, + "is_include_timestamps": { + "description": "Not returned for MariaDB.", + "type": "boolean" + }, + "include_coolify_default_variables": { + "type": "boolean" + }, + "redact_all_env_values_in_logs": { + "type": "boolean" + }, + "health_check_enabled": { + "type": "boolean" + }, + "health_check_interval": { + "type": "integer" + }, + "health_check_timeout": { + "type": "integer" + }, + "health_check_retries": { + "type": "integer" + }, + "health_check_start_period": { + "type": "integer" + }, + "limits_memory": { + "type": "string" + }, + "limits_memory_swap": { + "type": "string" + }, + "limits_memory_swappiness": { + "type": "integer" + }, + "limits_memory_reservation": { + "type": "string" + }, + "limits_cpus": { + "type": "string" + }, + "limits_cpuset": { + "type": [ + "string", + "null" + ] + }, + "limits_cpu_shares": { + "type": "integer" + }, + "config_hash": { + "description": "Internal hash that Coolify uses to find configuration changes that need a restart. It changes only when the database starts, so do not use it to compare configurations.", + "type": [ + "string", + "null" + ] + }, + "restart_count": { + "type": "integer" + }, + "last_restart_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_restart_type": { + "type": [ + "string", + "null" + ] + }, + "last_online_at": { + "type": [ + "string", + "null" + ] + }, + "started_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "backup_configs": { + "description": "Only returned by GET \/databases. The scheduled backups of the database, as returned by GET \/databases\/{uuid}\/backups.", + "type": "array", + "items": { + "type": "object" + } + }, + "postgres_user": { + "type": "string" + }, + "postgres_password": { + "type": "string", + "format": "password" + }, + "postgres_db": { + "type": "string" + }, + "postgres_initdb_args": { + "type": [ + "string", + "null" + ] + }, + "postgres_host_auth_method": { + "type": [ + "string", + "null" + ] + }, + "postgres_conf": { + "type": [ + "string", + "null" + ] + }, + "redis_password": { + "type": "string", + "format": "password" + }, + "redis_conf": { + "type": [ + "string", + "null" + ] + }, + "mongo_initdb_root_username": { + "type": "string" + }, + "mongo_initdb_root_password": { + "type": "string", + "format": "password" + }, + "mongo_initdb_database": { + "type": "string" + }, + "mongo_conf": { + "type": [ + "string", + "null" + ] + }, + "mysql_user": { + "type": "string" + }, + "mysql_password": { + "type": "string", + "format": "password" + }, + "mysql_root_password": { + "type": "string", + "format": "password" + }, + "mysql_database": { + "type": "string" + }, + "mysql_conf": { + "type": [ + "string", + "null" + ] + }, + "mariadb_user": { + "type": "string" + }, + "mariadb_password": { + "type": "string", + "format": "password" + }, + "mariadb_root_password": { + "type": "string", + "format": "password" + }, + "mariadb_database": { + "type": "string" + }, + "mariadb_conf": { + "type": [ + "string", + "null" + ] + }, + "keydb_password": { + "type": "string", + "format": "password" + }, + "keydb_conf": { + "type": [ + "string", + "null" + ] + }, + "dragonfly_password": { + "type": "string", + "format": "password" + }, + "clickhouse_admin_user": { + "type": "string" + }, + "clickhouse_admin_password": { + "type": "string", + "format": "password" + }, + "clickhouse_db": { + "type": "string" + }, + "sqlite_databases": { + "description": "Comma-separated SQLite database file names.", + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + }, + "type": "object" + }, "ApplicationPreview": { "properties": { "uuid": { @@ -25617,6 +26078,10 @@ "logdrain_axiom_dataset_name": { "type": "string" }, + "logdrain_axiom_domain": { + "type": "string", + "description": "Base domain of the Axiom edge deployment, for example us-east-1.aws.edge.axiom.co or eu-central-1.aws.edge.axiom.co." + }, "logdrain_cloudwatch_region": { "type": "string" }, @@ -26035,6 +26500,35 @@ } } } + }, + "502": { + "description": "The cloud provider API rejected the request.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Failed to create Hetzner server. The Hetzner API token does not have permission for this request. Use a token with read and write access." + }, + "provider_status": { + "description": "The HTTP status from the cloud provider API.", + "type": "integer", + "example": 403 + }, + "provider_error_code": { + "description": "The error code from the cloud provider API, if it has one.", + "type": [ + "string", + "null" + ], + "example": "forbidden" + } + }, + "type": "object" + } + } + } } }, "securitySchemes": { diff --git a/openapi.yaml b/openapi.yaml index 82076ff382..3de7ed2693 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -331,8 +331,6 @@ paths: required: - project_uuid - server_uuid - - environment_name - - environment_uuid - git_repository - git_branch - build_pack @@ -773,8 +771,6 @@ paths: required: - project_uuid - server_uuid - - environment_name - - environment_uuid - github_app_uuid - git_repository - git_branch @@ -1211,8 +1207,6 @@ paths: required: - project_uuid - server_uuid - - environment_name - - environment_uuid - private_key_uuid - git_repository - git_branch @@ -1649,8 +1643,6 @@ paths: required: - project_uuid - server_uuid - - environment_name - - environment_uuid - dockerfile properties: project_uuid: @@ -2020,8 +2012,6 @@ paths: required: - project_uuid - server_uuid - - environment_name - - environment_uuid - docker_registry_image_name properties: project_uuid: @@ -2907,7 +2897,7 @@ paths: - name: service_name in: query - description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no running container matches.' + description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no container matches. Stopped containers are included.' required: false schema: type: string @@ -2980,7 +2970,7 @@ paths: - name: service_name in: query - description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no running container matches.' + description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no container matches. Stopped containers are included.' required: false schema: type: string @@ -4554,8 +4544,9 @@ paths: content: application/json: schema: - type: string - example: 'Content is very complex. Will be implemented later.' + type: array + items: + $ref: '#/components/schemas/Database' '401': $ref: '#/components/responses/401' '400': @@ -4719,12 +4710,11 @@ paths: type: string responses: '200': - description: 'Get all databases' + description: 'Get database by UUID' content: application/json: schema: - type: string - example: 'Content is very complex. Will be implemented later.' + $ref: '#/components/schemas/Database' '401': $ref: '#/components/responses/401' '400': @@ -5163,8 +5153,6 @@ paths: required: - server_uuid - project_uuid - - environment_name - - environment_uuid properties: server_uuid: type: string @@ -5274,8 +5262,6 @@ paths: required: - server_uuid - project_uuid - - environment_name - - environment_uuid properties: server_uuid: type: string @@ -5373,8 +5359,6 @@ paths: required: - server_uuid - project_uuid - - environment_name - - environment_uuid properties: server_uuid: type: string @@ -5469,8 +5453,6 @@ paths: required: - server_uuid - project_uuid - - environment_name - - environment_uuid properties: server_uuid: type: string @@ -5568,8 +5550,6 @@ paths: required: - server_uuid - project_uuid - - environment_name - - environment_uuid properties: server_uuid: type: string @@ -5667,8 +5647,6 @@ paths: required: - server_uuid - project_uuid - - environment_name - - environment_uuid properties: server_uuid: type: string @@ -5775,8 +5753,6 @@ paths: required: - server_uuid - project_uuid - - environment_name - - environment_uuid properties: server_uuid: type: string @@ -5883,8 +5859,6 @@ paths: required: - server_uuid - project_uuid - - environment_name - - environment_uuid properties: server_uuid: type: string @@ -5982,8 +5956,6 @@ paths: required: - server_uuid - project_uuid - - environment_name - - environment_uuid properties: server_uuid: type: string @@ -7580,6 +7552,8 @@ paths: description: 'Validation failed.' '429': description: 'DigitalOcean rate limit exceeded.' + '502': + $ref: '#/components/responses/502' security: - bearerAuth: [] @@ -8161,6 +8135,8 @@ paths: $ref: '#/components/responses/401' '404': $ref: '#/components/responses/404' + '502': + $ref: '#/components/responses/502' security: - bearerAuth: [] @@ -8169,9 +8145,16 @@ paths: tags: - Hetzner summary: 'Get Hetzner Server Types' - description: 'Get all available Hetzner server types (instance sizes).' + description: 'Get the Hetzner server types (instance sizes) that can be ordered now. Each type lists only the locations where it is in stock and not deprecated.' operationId: get-hetzner-server-types parameters: + - + name: location + in: query + description: 'Return only the server types that can be ordered in this location, for example nbg1.' + required: false + schema: + type: string - name: cloud_provider_token_uuid in: query @@ -8201,6 +8184,8 @@ paths: $ref: '#/components/responses/401' '404': $ref: '#/components/responses/404' + '502': + $ref: '#/components/responses/502' security: - bearerAuth: [] @@ -8241,6 +8226,8 @@ paths: $ref: '#/components/responses/401' '404': $ref: '#/components/responses/404' + '502': + $ref: '#/components/responses/502' security: - bearerAuth: [] @@ -8281,6 +8268,8 @@ paths: $ref: '#/components/responses/401' '404': $ref: '#/components/responses/404' + '502': + $ref: '#/components/responses/502' security: - bearerAuth: [] @@ -8321,6 +8310,8 @@ paths: $ref: '#/components/responses/401' '404': $ref: '#/components/responses/404' + '502': + $ref: '#/components/responses/502' security: - bearerAuth: [] @@ -8361,6 +8352,8 @@ paths: $ref: '#/components/responses/401' '404': $ref: '#/components/responses/404' + '502': + $ref: '#/components/responses/502' security: - bearerAuth: [] @@ -8442,7 +8435,7 @@ paths: instant_validate: type: boolean example: false - description: 'Validate server immediately after creation' + description: 'Validate the server and install Docker when it is running. Defaults to true for tokens with the deploy permission, and needs that permission.' type: object responses: '201': @@ -8467,6 +8460,8 @@ paths: $ref: '#/components/responses/422' '429': $ref: '#/components/responses/429' + '502': + $ref: '#/components/responses/502' security: - bearerAuth: [] @@ -9420,12 +9415,15 @@ paths: operationId: list-resources responses: '200': - description: 'Get all resources' + description: 'Get all applications, services and standalone databases. The other fields of each resource are the fields of the Application, Service or Database schema.' content: application/json: schema: - type: string - example: 'Content is very complex. Will be implemented later.' + type: array + items: + properties: { uuid: { type: string }, name: { type: string }, description: { type: [string, 'null'] }, type: { description: 'application, service, or standalone- for databases (for example standalone-postgresql).', type: string }, status: { description: 'For example running:healthy or exited:unhealthy.', type: string }, environment_id: { type: integer }, created_at: { type: string, format: date-time }, updated_at: { type: string, format: date-time } } + type: object + additionalProperties: true '401': $ref: '#/components/responses/401' '400': @@ -9448,7 +9446,7 @@ paths: schema: type: array items: - properties: { uuid: { type: string }, name: { type: string }, description: { type: string, nullable: true }, endpoint: { type: string }, bucket: { type: string }, region: { type: string }, is_usable: { type: boolean }, team_id: { type: integer }, created_at: { type: string }, updated_at: { type: string } } + properties: { uuid: { type: string }, name: { type: string }, description: { type: string, nullable: true }, endpoint: { type: string }, bucket: { type: string }, region: { type: string }, use_path_style_endpoint: { type: boolean }, is_usable: { type: boolean }, team_id: { type: integer }, created_at: { type: string }, updated_at: { type: string } } type: object '401': $ref: '#/components/responses/401' @@ -9497,6 +9495,9 @@ paths: type: string example: us-east-1 description: 'S3 region.' + use_path_style_endpoint: + type: boolean + description: 'Use path-style URLs (endpoint/bucket). Set to false for providers that only accept virtual-hosted-style URLs (bucket.endpoint). Default: false for Alibaba Cloud OSS, Tencent Cloud COS, and Volcengine TOS endpoints, true for all other endpoints.' key: type: string description: 'Access key.' @@ -9553,6 +9554,7 @@ paths: endpoint: { type: string } bucket: { type: string } region: { type: string } + use_path_style_endpoint: { type: boolean } is_usable: { type: boolean } team_id: { type: integer } created_at: { type: string } @@ -9632,6 +9634,9 @@ paths: region: type: string description: 'S3 region.' + use_path_style_endpoint: + type: boolean + description: 'Use path-style URLs (endpoint/bucket). Set to false for providers that only accept virtual-hosted-style URLs (bucket.endpoint), such as Alibaba Cloud OSS.' key: type: string description: 'Access key.' @@ -10941,6 +10946,7 @@ paths: logdrain_newrelic_base_uri: { type: [string, 'null'] } is_logdrain_axiom_enabled: { type: boolean } logdrain_axiom_dataset_name: { type: [string, 'null'] } + logdrain_axiom_domain: { type: [string, 'null'] } logdrain_axiom_api_key: { description: 'Only present with read:sensitive.', type: string } is_logdrain_custom_enabled: { type: boolean } logdrain_custom_config: { description: 'Only present with read:sensitive.', type: string } @@ -10989,6 +10995,9 @@ paths: type: boolean logdrain_axiom_dataset_name: type: string + logdrain_axiom_domain: + description: 'Base domain of the Axiom edge deployment. Default: us-east-1.aws.edge.axiom.co. Use eu-central-1.aws.edge.axiom.co for EU datasets.' + type: string logdrain_axiom_api_key: type: string is_logdrain_custom_enabled: @@ -12825,8 +12834,6 @@ paths: required: - server_uuid - project_uuid - - environment_name - - environment_uuid properties: type: description: 'The one-click service type (e.g. "actualbudget", "calibre-web", "gitea-with-mysql" ...)' @@ -15233,6 +15240,8 @@ paths: description: 'Validation failed.' '429': description: 'Vultr API rate limit exceeded.' + '502': + $ref: '#/components/responses/502' security: - bearerAuth: [] @@ -15374,6 +15383,282 @@ components: - 'null' format: date-time type: object + Database: + description: 'Standalone database. The fields that start with an engine name (for example postgres_ or redis_) are only returned for that engine. Passwords, connection URLs and init_scripts are only returned for tokens with the read:sensitive or root ability.' + properties: + uuid: + type: string + name: + type: string + description: + type: + - string + - 'null' + database_type: + type: string + enum: + - standalone-postgresql + - standalone-redis + - standalone-mongodb + - standalone-mysql + - standalone-mariadb + - standalone-keydb + - standalone-dragonfly + - standalone-clickhouse + - standalone-sqlite + image: + type: string + status: + description: 'Container status and health, for example running:healthy or exited:unhealthy.' + type: string + server_status: + description: 'True when the server of the database is reachable and usable.' + type: boolean + environment_id: + type: integer + environment: + $ref: '#/components/schemas/Environment' + description: 'Only returned by GET /databases/{uuid}. Includes the project.' + destination_id: + type: integer + destination_type: + type: string + destination: + description: 'Docker network destination of the database, with its server.' + properties: + uuid: + type: string + name: + type: string + network: + type: string + server_id: + type: integer + server: + $ref: '#/components/schemas/Server' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + type: object + is_public: + description: 'Not returned for SQLite.' + type: boolean + public_port: + description: 'Not returned for SQLite.' + type: + - integer + - 'null' + public_port_timeout: + description: 'Proxy timeout of the public port in seconds. Not returned for SQLite.' + type: + - integer + - 'null' + ports_mappings: + description: 'Comma-separated host:container port mappings. Not returned for SQLite.' + type: + - string + - 'null' + enable_ssl: + description: 'Not returned for ClickHouse and SQLite.' + type: boolean + ssl_mode: + description: 'PostgreSQL, MongoDB and MySQL only.' + type: + - string + - 'null' + internal_db_url: + description: 'Connection URL inside the Docker network. Contains the password. Not returned for SQLite.' + type: + - string + - 'null' + external_db_url: + description: 'Public connection URL, when the database is public. Contains the password. Not returned for SQLite.' + type: + - string + - 'null' + init_scripts: + description: 'PostgreSQL only.' + type: + - array + - 'null' + items: + properties: + index: + type: integer + filename: + type: string + content: + type: string + type: object + custom_docker_run_options: + type: + - string + - 'null' + is_log_drain_enabled: + type: boolean + is_include_timestamps: + description: 'Not returned for MariaDB.' + type: boolean + include_coolify_default_variables: + type: boolean + redact_all_env_values_in_logs: + type: boolean + health_check_enabled: + type: boolean + health_check_interval: + type: integer + health_check_timeout: + type: integer + health_check_retries: + type: integer + health_check_start_period: + type: integer + limits_memory: + type: string + limits_memory_swap: + type: string + limits_memory_swappiness: + type: integer + limits_memory_reservation: + type: string + limits_cpus: + type: string + limits_cpuset: + type: + - string + - 'null' + limits_cpu_shares: + type: integer + config_hash: + description: 'Internal hash that Coolify uses to find configuration changes that need a restart. It changes only when the database starts, so do not use it to compare configurations.' + type: + - string + - 'null' + restart_count: + type: integer + last_restart_at: + type: + - string + - 'null' + format: date-time + last_restart_type: + type: + - string + - 'null' + last_online_at: + type: + - string + - 'null' + started_at: + type: + - string + - 'null' + format: date-time + backup_configs: + description: 'Only returned by GET /databases. The scheduled backups of the database, as returned by GET /databases/{uuid}/backups.' + type: array + items: + type: object + postgres_user: + type: string + postgres_password: + type: string + format: password + postgres_db: + type: string + postgres_initdb_args: + type: + - string + - 'null' + postgres_host_auth_method: + type: + - string + - 'null' + postgres_conf: + type: + - string + - 'null' + redis_password: + type: string + format: password + redis_conf: + type: + - string + - 'null' + mongo_initdb_root_username: + type: string + mongo_initdb_root_password: + type: string + format: password + mongo_initdb_database: + type: string + mongo_conf: + type: + - string + - 'null' + mysql_user: + type: string + mysql_password: + type: string + format: password + mysql_root_password: + type: string + format: password + mysql_database: + type: string + mysql_conf: + type: + - string + - 'null' + mariadb_user: + type: string + mariadb_password: + type: string + format: password + mariadb_root_password: + type: string + format: password + mariadb_database: + type: string + mariadb_conf: + type: + - string + - 'null' + keydb_password: + type: string + format: password + keydb_conf: + type: + - string + - 'null' + dragonfly_password: + type: string + format: password + clickhouse_admin_user: + type: string + clickhouse_admin_password: + type: string + format: password + clickhouse_db: + type: string + sqlite_databases: + description: 'Comma-separated SQLite database file names.' + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + deleted_at: + type: + - string + - 'null' + format: date-time + type: object ApplicationPreview: properties: uuid: @@ -16411,6 +16696,9 @@ components: type: string logdrain_axiom_dataset_name: type: string + logdrain_axiom_domain: + type: string + description: 'Base domain of the Axiom edge deployment, for example us-east-1.aws.edge.axiom.co or eu-central-1.aws.edge.axiom.co.' logdrain_cloudwatch_region: type: string logdrain_cloudwatch_group: @@ -16699,6 +16987,26 @@ components: type: string example: 'Rate limit exceeded. Please try again later.' type: object + '502': + description: 'The cloud provider API rejected the request.' + content: + application/json: + schema: + properties: + message: + type: string + example: 'Failed to create Hetzner server. The Hetzner API token does not have permission for this request. Use a token with read and write access.' + provider_status: + description: 'The HTTP status from the cloud provider API.' + type: integer + example: 403 + provider_error_code: + description: 'The error code from the cloud provider API, if it has one.' + type: + - string + - 'null' + example: forbidden + type: object securitySchemes: bearerAuth: type: http diff --git a/other/nightly/versions.json b/other/nightly/versions.json index d0c384b345..3c8c027b57 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -10,11 +10,11 @@ "version": "1.0.19" }, "sentinel": { - "version": "1.1.2" + "version": "1.1.3" }, "sentinel-host": { - "version": "1.1.2", - "digest": "sha256:149d2097943716d30af9659eeeb11259601335442242ecb0b0331982a82a3bd2" + "version": "1.1.3", + "digest": "sha256:bca77d22355c9005185b438dad406747b4b776018215a0a4a75c88618d681ff7" } }, "traefik": { diff --git a/resources/js/terminal-connection.js b/resources/js/terminal-connection.js index f2f463f94a..9bee050641 100644 --- a/resources/js/terminal-connection.js +++ b/resources/js/terminal-connection.js @@ -20,35 +20,17 @@ export const TERMINAL_CONNECTION_ERRORS = Object.freeze({ timeout: 'Timed out while connecting to the terminal. Reload the page and try again.', }); -const AUTH_REJECTION_MESSAGES = new Set([ - 'Unauthorized: Missing required tokens', - 'Unauthorized: Invalid terminal token', - 'Unauthorized: Terminal token was rejected', -]); - export function isTerminalAuthRejectionCode(code) { return code === TERMINAL_CLOSE_CODES.AUTH_REJECTED || code === TERMINAL_CLOSE_CODES.TOKEN_REJECTED; } /** - * Classifies a plain-text message from the terminal server. - * - * @returns {'auth-rejected'|'startup-rejected'|null} + * Whether a plain-text message from the terminal server rejects a session start, + * for example a target host that is not authorized or an invalid SSH command. + * Authentication rejections arrive as close codes instead. */ -export function classifyTerminalServerMessage(data) { - if (typeof data !== 'string') { - return null; - } - - if (AUTH_REJECTION_MESSAGES.has(data)) { - return 'auth-rejected'; - } - - if (data.startsWith('Unauthorized:') || data.startsWith('Invalid SSH command:')) { - return 'startup-rejected'; - } - - return null; +export function isTerminalStartupRejection(data) { + return typeof data === 'string' && (data.startsWith('Unauthorized:') || data.startsWith('Invalid SSH command:')); } /** diff --git a/resources/js/terminal-connection.test.js b/resources/js/terminal-connection.test.js index 2f0827283f..e6c7e4fafb 100644 --- a/resources/js/terminal-connection.test.js +++ b/resources/js/terminal-connection.test.js @@ -5,8 +5,8 @@ import { TERMINAL_CONNECT_TIMEOUT_MS, TERMINAL_CONNECTION_ERRORS, TERMINAL_SESSION_START_TIMEOUT_MS, - classifyTerminalServerMessage, isTerminalAuthRejectionCode, + isTerminalStartupRejection, resolveTerminalCloseOutcome, } from './terminal-connection.js'; @@ -70,15 +70,12 @@ test('an idle close reconnects silently until the retry limit, then reports an e }); }); -test('server text messages are classified for backward compatible rejection handling', () => { - assert.equal(classifyTerminalServerMessage('Unauthorized: Invalid terminal token'), 'auth-rejected'); - assert.equal(classifyTerminalServerMessage('Unauthorized: Terminal token was rejected'), 'auth-rejected'); - assert.equal(classifyTerminalServerMessage('Unauthorized: Missing required tokens'), 'auth-rejected'); - assert.equal(classifyTerminalServerMessage('Unauthorized: Target host 10.0.0.5 not in authorized list'), 'startup-rejected'); - assert.equal(classifyTerminalServerMessage('Invalid SSH command: No target host found'), 'startup-rejected'); - assert.equal(classifyTerminalServerMessage('pty-ready'), null); - assert.equal(classifyTerminalServerMessage('root@host:~# '), null); - assert.equal(classifyTerminalServerMessage(new Blob([])), null); +test('session start rejections from the terminal server are detected', () => { + assert.equal(isTerminalStartupRejection('Unauthorized: Target host 10.0.0.5 not in authorized list'), true); + assert.equal(isTerminalStartupRejection('Invalid SSH command: No target host found'), true); + assert.equal(isTerminalStartupRejection('pty-ready'), false); + assert.equal(isTerminalStartupRejection('root@host:~# '), false); + assert.equal(isTerminalStartupRejection(new Blob([])), false); }); test('connection timeouts stay within a bounded window', () => { diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 46495425ef..2f125e92b5 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -9,7 +9,7 @@ import { import { TERMINAL_CONNECT_TIMEOUT_MS, TERMINAL_CONNECTION_ERRORS, - classifyTerminalServerMessage, + isTerminalStartupRejection, resolveTerminalCloseOutcome, terminalSessionStartMethods, } from './terminal-connection.js'; @@ -1107,18 +1107,12 @@ export function initializeTerminalComponent() { // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); - } else if (classifyTerminalServerMessage(event.data) !== null) { + } else if (isTerminalStartupRejection(event.data)) { logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data); this.pendingCommand = null; this.terminalActive = false; this.resetTerminalSessionCountdown(); - // Newer servers follow an auth rejection with a 4401/4403 close. - this.failTerminalConnection( - classifyTerminalServerMessage(event.data) === 'auth-rejected' - ? TERMINAL_CONNECTION_ERRORS.authRejected - : event.data, - { override: true }, - ); + this.failTerminalConnection(event.data, { override: true }); } else { try { this.pendingWrites++; diff --git a/resources/views/components/breadcrumb-switcher.blade.php b/resources/views/components/breadcrumb-switcher.blade.php index 73c9bd8a1a..3f739544c0 100644 --- a/resources/views/components/breadcrumb-switcher.blade.php +++ b/resources/views/components/breadcrumb-switcher.blade.php @@ -1,4 +1,4 @@ -@props(['label', 'items', 'title']) +@props(['label', 'items', 'title', 'uuid' => null]) /
- {{ $label }} + {{ $label }} @@ -33,9 +34,10 @@
@foreach ($items as $item) - {{ $item['label'] }} + {{ $item['label'] }} @endforeach diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index d0d65d2d37..50e41d72df 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -369,13 +369,17 @@ class="block text-sm font-medium text-gray-700 dark:text-gray-300"> Your Password -
- - -
+ {{-- Add the field when this step shows: password managers do not detect a hidden field that becomes visible. --}} +

@error('password') diff --git a/resources/views/components/shared-variables/editor.blade.php b/resources/views/components/shared-variables/editor.blade.php index 2b2c13cb30..18ed216172 100644 --- a/resources/views/components/shared-variables/editor.blade.php +++ b/resources/views/components/shared-variables/editor.blade.php @@ -156,8 +156,12 @@ @endforeach @endif -
+ + + To make a variable literal, put # coolify: literal directly above + its line. Without this line, the flag does not change. + diff --git a/resources/views/components/top-breadcrumb.blade.php b/resources/views/components/top-breadcrumb.blade.php index 64bebc9381..2c5f66287c 100644 --- a/resources/views/components/top-breadcrumb.blade.php +++ b/resources/views/components/top-breadcrumb.blade.php @@ -39,6 +39,7 @@ ->sortBy(fn ($item) => strtolower($item['resource']->name)) ->map(fn ($item) => [ 'label' => $item['resource']->name, + 'uuid' => $item['resource']->uuid, 'href' => match ($item['type']) { 'application' => route('project.application.configuration', [ 'project_uuid' => $currentProject->uuid, @@ -107,6 +108,10 @@
+ x-init="syncTitle()" x-on:livewire:navigated.window="$nextTick(() => syncTitle())" + x-on:name-changed.window="renamed[$event.detail.uuid] = $event.detail.name"> {{-- Team --}}
@@ -129,8 +135,9 @@ @endif @if ($currentStorage) - @@ -151,8 +158,9 @@ ? filled($currentSource->installation_id) : filled($currentSource->access_token); @endphp - @@ -210,7 +219,8 @@
@@ -257,7 +267,7 @@ @endif @if ($currentResource) - + {{-- Below xl, the page heading shows the status. --}}